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. Every signed data response is signed the same way, whichever payment rail delivered it:

signing process
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
Standard Ed25519 (RFC 8032)

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.

Responses signed before 19 September 2026

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:

canonical format
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
PositionFieldExample
0Versionv1
1TypePRICE or ECON
2..N-2Payload fields (type-specific)See attestation spec
N-1Timestamp (Unix integer)1741514400
NNonce (6 digits)482910
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. 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)

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

javascript — Node.js, no dependencies
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)

rust — cargo add ed25519-dalek@2 hex
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.

the bip340 object
"bip340": {
  "scheme": "bip340",
  "message": "sha256(canonical)",
  "pubkey": "3d436ccaeaea1fc89383b7dcfe0e8d8cce7244bf6426c7cc54f7bcbe5540ec9e",
  "signature": "…128 hex characters…"
}
python — pip install coincurve
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
javascript — npm install @noble/curves (2.x)
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.

python
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

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.

Don't hash first

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.

Check which key signed

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.

Checking source count

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.