Signature Verification
How to cryptographically verify that an attestation was signed by Mycelia Signal and has not been tampered with.
Always verify the signature before trusting oracle data. Without verification, you have no guarantee the response wasn't modified in transit. This is your primary defense against data tampering.
How Signing Works
Every oracle response contains a canonical string — a deterministic, pipe-delimited representation of the attestation. Every signed data response is signed the same way, whichever payment rail delivered it:
1. Build canonical string → "v1|PRICE|BTCUSD|84231.50|USD|2|binance,...,kraken|median|1741514400|890123" 2. Sign its UTF-8 bytes → Ed25519_sign(private_key, canonical_bytes) # RFC 8032, no pre-hash 3. Hex-encode the signature → response.signature # 64 bytes, 128 hex characters
The canonical string's UTF-8 bytes are the signed message, exactly as RFC 8032 defines Ed25519. There is no pre-hash: Ed25519 already hashes internally with SHA-512 as part of the algorithm. Any standard Ed25519 library verifies it directly — pass the canonical bytes, the hex-decoded signature and the node's public key.
| Delivered via | Scheme | Signed message | Signature Field | Public Key Format |
|---|---|---|---|---|
| x402 (USDC on Base) and L402 (Lightning) | Ed25519 (RFC 8032) | Canonical string, UTF-8 bytes | Hex (64 bytes) | Hex (32 bytes), one key per node |
| L402 only (additional) | BIP-340 Schnorr (secp256k1) | SHA-256 of the canonical's UTF-8 bytes | bip340.signature, hex (64 bytes) |
x-only hex (32 bytes), GET /keys/bip340 |
The payment rail decides how you pay, not how the data is signed: Lightning secures the L402 payment itself, and the attestation in the response is the same Ed25519 signature an x402 caller receives. Two nodes sign, each with its own key, listed on the Public Keys page. L402 responses also carry a second attestation for Bitcoin tooling — see Lightning: BIP-340 attestation below.
Until 19 September 2026 several services signed a SHA-256 or SHA-512 digest of the canonical instead of the canonical itself, with the signature hex- or base64-encoded. Archived responses from before the change verify under the scheme they were signed with; every response since verifies as described here.
Canonical String Format
The canonical string is the source of truth. It's what the signature covers. The format is:
v1|TYPE|PAIR|PRICE|CURRENCY|DECIMALS|SOURCES|METHOD|TIMESTAMP|NONCE Example (PRICE type): v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken|median|1741514400|482910 Example (ECON type): v1|ECON|US|CPI|326.785|index198284100|2026-02|2026-03-14|BLS|CUUR0000SA0|directapi|1741514400|830114
| Position | Field | Example |
|---|---|---|
0 | Version | v1 |
1 | Type | PRICE or ECON |
2..N-2 | Payload fields (type-specific) | See attestation spec |
N-1 | Timestamp (Unix integer) | 1741514400 |
N | Nonce (6 digits) | 482910 |
Sources are sorted alphabetically in the canonical string. When verifying, always use the canonical field directly from the response — don't reconstruct it from other fields, as the nonce is server-generated and cannot be reproduced.
Verification Steps
To verify an attestation:
1. Extract the canonical, signature, and pubkey fields from the response.
2. Check that pubkey is one of the published keys on the Public Keys page. A response always verifies against a key it supplies itself, so this check is what ties it to Mycelia Signal.
3. Hex-decode the signature (64 bytes) and the public key (32 bytes).
4. Verify the signature over the canonical string's UTF-8 bytes with standard Ed25519. Do not hash the canonical first.
5. Optionally, parse the canonical string to extract the price, sources, and timestamp for your application.
Code Examples
Verify an attestation (Ed25519)
from nacl.signing import VerifyKey
# Published Ed25519 keys, one per signing node (see /docs/keys/ or GET /keys/ed25519)
KEYS = {
"f4f0e52b5f7b54831f965632bf1ebf72769beda4c4e3d36a593f7729ec812615", # US node
"7ab07fbe7d08cd16823e5eb0db0e21f3f38e9366d5fd00d14e95df0fb9b51a1a", # Asia node
}
def verify(response: dict) -> bool:
"""Verify a Mycelia Signal attestation, delivered via x402 or L402."""
if response.get("pubkey") not in KEYS:
return False # signed by an unpublished key
try:
VerifyKey(bytes.fromhex(response["pubkey"])).verify(
response["canonical"].encode("utf-8"),
bytes.fromhex(response["signature"]),
)
return True
except Exception:
return False
# Usage (PRICE type)
if verify(response):
fields = response["canonical"].split("|")
pair, price, sources, timestamp = fields[2], fields[3], fields[6], fields[8]
print(f"Verified {pair}: {price} from {sources}")
else:
print("SIGNATURE INVALID — do not trust this data")
Verify an attestation (Ed25519)
import { createPublicKey, verify } from "node:crypto";
// Published Ed25519 keys, one per signing node (see /docs/keys/ or GET /keys/ed25519)
const KEYS = new Set([
"f4f0e52b5f7b54831f965632bf1ebf72769beda4c4e3d36a593f7729ec812615", // US node
"7ab07fbe7d08cd16823e5eb0db0e21f3f38e9366d5fd00d14e95df0fb9b51a1a", // Asia node
]);
function verifyAttestation(r) {
if (!KEYS.has(r.pubkey)) return false; // signed by an unpublished key
const key = createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(r.pubkey, "hex")]),
format: "der",
type: "spki",
});
return verify(null, Buffer.from(r.canonical, "utf8"), key, Buffer.from(r.signature, "hex"));
}
// Usage (PRICE type)
if (verifyAttestation(response)) {
const fields = response.canonical.split("|");
console.log(`Verified ${fields[2]}: ${fields[3]} from ${fields[6]}`);
} else {
console.error("SIGNATURE INVALID");
}
Verify an attestation (Ed25519)
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
// Published Ed25519 keys, one per signing node (see /docs/keys/ or GET /keys/ed25519)
const KEYS: [&str; 2] = [
"f4f0e52b5f7b54831f965632bf1ebf72769beda4c4e3d36a593f7729ec812615", // US node
"7ab07fbe7d08cd16823e5eb0db0e21f3f38e9366d5fd00d14e95df0fb9b51a1a", // Asia node
];
fn verify(canonical: &str, signature_hex: &str, pubkey_hex: &str) -> bool {
if !KEYS.contains(&pubkey_hex) {
return false; // signed by an unpublished key
}
let (Ok(pk), Ok(sig)) = (hex::decode(pubkey_hex), hex::decode(signature_hex)) else { return false };
let (Ok(pk), Ok(sig)) = (<[u8; 32]>::try_from(pk.as_slice()), <[u8; 64]>::try_from(sig.as_slice())) else { return false };
let Ok(key) = VerifyingKey::from_bytes(&pk) else { return false };
key.verify(canonical.as_bytes(), &Signature::from_bytes(&sig)).is_ok()
}
// Usage (PRICE type)
if verify(&response.canonical, &response.signature, &response.pubkey) {
let fields: Vec<&str> = response.canonical.split('|').collect();
println!("Verified {}: {} from {}", fields[2], fields[3], fields[6]);
}
Lightning (L402): BIP-340 Attestation
Every signed L402 response carries, besides its Ed25519 signature, a bip340 object: a BIP-340 Schnorr signature over SHA-256 of the same canonical string, made with the Lightning rail's key. Bitcoin tooling verifies it with the secp256k1 library it already uses. Either signature is sufficient on its own; this one exists so you do not need an Ed25519 library.
"bip340": {
"scheme": "bip340",
"message": "sha256(canonical)",
"pubkey": "3d436ccaeaea1fc89383b7dcfe0e8d8cce7244bf6426c7cc54f7bcbe5540ec9e",
"signature": "…128 hex characters…"
}
import hashlib
from coincurve import PublicKeyXOnly
BIP340_KEY = "3d436ccaeaea1fc89383b7dcfe0e8d8cce7244bf6426c7cc54f7bcbe5540ec9e" # GET /keys/bip340
def verify_bip340(response: dict) -> bool:
"""Verify the BIP-340 attestation on an L402 (Lightning) response."""
b = response.get("bip340")
if not b or b.get("pubkey") != BIP340_KEY:
return False # absent, or signed by an unpublished key
msg = hashlib.sha256(response["canonical"].encode("utf-8")).digest()
try:
return PublicKeyXOnly(bytes.fromhex(b["pubkey"])).verify(bytes.fromhex(b["signature"]), msg)
except Exception:
return False
import { schnorr } from "@noble/curves/secp256k1.js";
import { createHash } from "node:crypto";
const BIP340_KEY = "3d436ccaeaea1fc89383b7dcfe0e8d8cce7244bf6426c7cc54f7bcbe5540ec9e"; // GET /keys/bip340
function verifyBip340(r) {
const b = r.bip340;
if (!b || b.pubkey !== BIP340_KEY) return false; // absent, or signed by an unpublished key
const msg = createHash("sha256").update(r.canonical, "utf8").digest();
try {
return schnorr.verify(Buffer.from(b.signature, "hex"), msg, Buffer.from(b.pubkey, "hex"));
} catch {
return false;
}
}
Extracting Data from the Canonical String
Once verification succeeds, parse the canonical string to extract the fields you need. The canonical string is the signed source of truth — always extract data from it rather than from any other response fields.
fields = canonical.split("|") version = fields[0] # "v1" type_id = fields[1] # "PRICE" pair = fields[2] # "BTCUSD" price = fields[3] # "84231.50" currency = fields[4] # "USD" decimals = fields[5] # "2" sources = fields[6].split(",") # ["binance", "bitstamp", ...] method = fields[7] # "median" timestamp = fields[8] # "1741514400" nonce = fields[9] # "890123"
Common Pitfalls
The nonce is server-generated. You cannot reproduce it. Always verify against the canonical field provided in the response.
The signed message is the canonical string's UTF-8 bytes. Pass them to the verify function as they are: hashing first (for example with SHA-256) makes every current signature fail. Only responses signed before 19 September 2026 used a pre-hash.
Every signed response carries "signingScheme": "ed25519" and the signing node's key in pubkey, on either payment rail. Accept only keys listed on the Public Keys page.
After verification, you may want to validate that the attestation meets your own source diversity requirements. Parse fields[6].split(",") (PRICE type) and check len(sources) against your minimum threshold.