Signature Verification

How to cryptographically verify that an attestation was signed by Mycelia Signal and has not been tampered with.

Critical

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:

signing process
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
Implementation detail: pre-hashed signing

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:

canonical format
v1|PAIR|PRICE|CURRENCY|DECIMALS|TIMESTAMP|NONCE|SOURCES|METHOD
PositionFieldExample
0Versionv1
1PairBTCUSD
2Price84231.50
3CurrencyUSD
4Decimals2
5Timestamp (ISO 8601 UTC)2026-02-28T07:51:00Z
6Nonce890123
7Sources (comma-separated, sorted)binance,bitstamp,coinbase,...
8Methodmedian
Note

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)

python — pip install pynacl
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)

python — pip install 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)

javascript — npm install @noble/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)

javascript — npm install @noble/secp256k1
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)

rust — cargo add ed25519-dalek sha2 base64 hex
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)

rust — cargo add k256 sha2 base64 hex
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.

python
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

Don't reconstruct the canonical string

The nonce is server-generated. You cannot reproduce it. Always verify against the canonical field provided in the response.

Hash before verifying

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.

Check the signing scheme

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.

Checking source count

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.