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. The signing process is identical for both protocols:
1. Build canonical string → "v1|BTCUSD|84231.50|USD|2|2026-02-28T07:51:00Z|890123|binance,...|median" 2. SHA-256 hash → sha256(canonical_bytes) 3. Sign the digest → sign(hash, private_key) 4. Base64-encode signature → response.signature
Both protocols sign the SHA-256 digest of the canonical string, not the raw canonical bytes. For secp256k1 (L402), this is standard practice — ECDSA always operates on a hash. For Ed25519 (x402), standard RFC 8032 Ed25519 expects a raw message and hashes internally with SHA-512. Mycelia Signal instead passes the SHA-256 digest as the message to Ed25519, meaning the actual signing operation is Ed25519(SHA-256(canonical)). This is functionally sound and fully verifiable, but your verification code must hash first and verify the digest — not pass the raw canonical string to the Ed25519 verify function.
The difference between protocols is only the signature scheme:
| Protocol | Scheme | Hash | Signature Field | Public Key Format |
|---|---|---|---|---|
| L402 (Lightning) | secp256k1 ECDSA | SHA-256 | Base64 DER-encoded | Compressed hex (33 bytes) |
| x402 (USDC on Base) | Ed25519 | SHA-256 | Base64 raw (64 bytes) | Hex (32 bytes) |
Canonical String Format
The canonical string is the source of truth. It's what the signature covers. The format is:
v1|PAIR|PRICE|CURRENCY|DECIMALS|TIMESTAMP|NONCE|SOURCES|METHOD
| Position | Field | Example |
|---|---|---|
0 | Version | v1 |
1 | Pair | BTCUSD |
2 | Price | 84231.50 |
3 | Currency | USD |
4 | Decimals | 2 |
5 | Timestamp (ISO 8601 UTC) | 2026-02-28T07:51:00Z |
6 | Nonce | 890123 |
7 | Sources (comma-separated, sorted) | binance,bitstamp,coinbase,... |
8 | Method | median |
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. SHA-256 hash the canonical string (UTF-8 encoded bytes).
3. Base64-decode the signature.
4. Verify the signature over the hash using the public key and the appropriate scheme.
5. Optionally, parse the canonical string to extract the price, sources, and timestamp for your application.
Code Examples
Verify x402 (Ed25519)
import hashlib, base64 from nacl.signing import VerifyKey from nacl.encoding import HexEncoder def verify_x402(response: dict) -> bool: """Verify an x402 (Ed25519) attestation.""" canonical = response["canonical"] signature = base64.b64decode(response["signature"]) pubkey = response["pubkey"] # SHA-256 hash the canonical string msg_hash = hashlib.sha256(canonical.encode()).digest() # Load the public key vk = VerifyKey(pubkey, encoder=HexEncoder) # Verify: raises nacl.exceptions.BadSignatureError if invalid try: vk.verify(msg_hash, signature) return True except Exception: return False # Usage response = { "canonical": "v1|BTCUSD|84231.50|USD|2|2026-02-28T07:51:00Z|890123|binance,...|median", "signature": "<base64-encoded-signature>", "pubkey": "<hex-encoded-ed25519-pubkey>", } if verify_x402(response): # Safe to parse the canonical string fields = response["canonical"].split("|") price = fields[2] # "84231.50" sources = fields[7] # "binance,bitstamp,..." timestamp = fields[5] # "2026-02-28T07:51:00Z" print(f"Verified BTC/USD: ${price} from {sources}") else: print("SIGNATURE INVALID — do not trust this data")
Verify L402 (secp256k1 ECDSA)
import hashlib, base64 from ecdsa import VerifyingKey, SECP256k1 def verify_l402(response: dict) -> bool: """Verify an L402 (secp256k1 ECDSA) attestation.""" canonical = response["canonical"] signature = base64.b64decode(response["signature"]) pubkey_hex = response["pubkey"] # SHA-256 hash the canonical string msg_hash = hashlib.sha256(canonical.encode()).digest() # Load compressed public key vk = VerifyingKey.from_string( bytes.fromhex(pubkey_hex), curve=SECP256k1 ) # Verify: raises ecdsa.BadSignatureError if invalid try: vk.verify_digest(signature, msg_hash) return True except Exception: return False
Verify x402 (Ed25519)
import * as ed from '@noble/ed25519'; async function verifyX402(response) { const { canonical, signature, pubkey } = response; // SHA-256 hash the canonical string const encoder = new TextEncoder(); const msgHash = new Uint8Array( await crypto.subtle.digest('SHA-256', encoder.encode(canonical)) ); // Decode signature from base64 const sigBytes = Uint8Array.from(atob(signature), c => c.charCodeAt(0)); // Decode public key from hex const pubkeyBytes = hexToBytes(pubkey); // Verify return await ed.verifyAsync(sigBytes, msgHash, pubkeyBytes); } function hexToBytes(hex) { return Uint8Array.from(hex.match(/.{2}/g).map(b => parseInt(b, 16))); } // Usage const valid = await verifyX402(response); if (valid) { const fields = response.canonical.split('|'); console.log(`Verified: $${fields[2]} from ${fields[7]}`); } else { console.error('SIGNATURE INVALID'); }
Verify L402 (secp256k1 ECDSA)
import * as secp from '@noble/secp256k1'; async function verifyL402(response) { const { canonical, signature, pubkey } = response; // SHA-256 hash the canonical string const encoder = new TextEncoder(); const msgHash = new Uint8Array( await crypto.subtle.digest('SHA-256', encoder.encode(canonical)) ); // Decode DER signature from base64 const sigBytes = Uint8Array.from(atob(signature), c => c.charCodeAt(0)); const sig = secp.Signature.fromDER(sigBytes); // Decode compressed public key from hex const pubkeyBytes = hexToBytes(pubkey); // Verify return secp.verify(sig, msgHash, pubkeyBytes); }
Verify x402 (Ed25519)
use ed25519_dalek::{PublicKey, Signature, Verifier}; use sha2::{Sha256, Digest}; use base64::Engine; use base64::engine::general_purpose::STANDARD; fn verify_x402(canonical: &str, sig_b64: &str, pubkey_hex: &str) -> bool { // SHA-256 hash the canonical string let hash = Sha256::digest(canonical.as_bytes()); // Decode signature from base64 let sig_bytes = STANDARD.decode(sig_b64).unwrap(); let signature = Signature::from_bytes(&sig_bytes).unwrap(); // Decode public key from hex let pk_bytes = hex::decode(pubkey_hex).unwrap(); let public_key = PublicKey::from_bytes(&pk_bytes).unwrap(); // Verify signature over the hash public_key.verify(&hash, &signature).is_ok() } // Usage let valid = verify_x402( &response.canonical, &response.signature, &response.pubkey, ); if valid { let fields: Vec<&str> = response.canonical.split('|').collect(); println!("Verified: {} from {}", fields[2], fields[7]); }
Verify L402 (secp256k1 ECDSA)
use k256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; use sha2::{Sha256, Digest}; use base64::Engine; use base64::engine::general_purpose::STANDARD; fn verify_l402(canonical: &str, sig_b64: &str, pubkey_hex: &str) -> bool { // SHA-256 hash the canonical string let hash = Sha256::digest(canonical.as_bytes()); // Decode DER signature from base64 let sig_bytes = STANDARD.decode(sig_b64).unwrap(); let signature = Signature::from_der(&sig_bytes).unwrap(); // Decode compressed public key from hex let pk_bytes = hex::decode(pubkey_hex).unwrap(); let verifying_key = VerifyingKey::from_sec1_bytes(&pk_bytes).unwrap(); // Verify signature over the hash verifying_key.verify(&hash, &signature).is_ok() }
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" pair = fields[1] # "BTCUSD" price = fields[2] # "84231.50" currency = fields[3] # "USD" decimals = fields[4] # "2" timestamp = fields[5] # "2026-02-28T07:51:00Z" nonce = fields[6] # "890123" sources = fields[7].split(",") # ["binance", "bitstamp", ...] method = fields[8] # "median"
Common Pitfalls
The nonce is server-generated. You cannot reproduce it. Always verify against the canonical field provided in the response.
Mycelia Signal signs the SHA-256 hash of the canonical string, not the raw string. If you pass the raw bytes to the verify function, the signature will always fail.
If the response includes "signing_scheme": "ed25519", use Ed25519 verification. If it doesn't include this field, the signature is secp256k1 ECDSA (L402 path). Using the wrong scheme will always fail.
After verification, you may want to validate that the attestation meets your own source diversity requirements. Parse fields[7] and check len(sources) against your minimum threshold.