Skip to main content

The Varnir SDK core contract

This is the language-agnostic reference a Varnir SDK port (Go, Python, Rust, …) implements against. It exists because the TypeScript SDK (@varnir/chain-client) is, today, the only description of the wire protocol — a port that reverse-engineers behaviour from V8's JSON.stringify and one implementation's control flow will eventually diverge from it in a way that produces a validly-formed but wrongly-signed transaction: one the ledger silently rejects, or — worse, if a divergence is ever also present on the contract side — silently accepts with the wrong meaning.

Everything here is grounded in the current TypeScript implementation, cited by file:line. Where the current code's behaviour is a historical accident rather than a deliberate design (Section 1 in particular), that is called out explicitly, because a port has to decide whether to reproduce the accident or diverge from it — and that decision belongs to a human, not to whichever language happens to make the "obvious" choice differently.

This specification now has eight more implementations

varnir for Python, the Kotlin/Java SDK, the .NET SDK, Go, PHP, Rust, Dart and Swift are all written against this document.

§2.2 requires low-S normalisation, inherited from the reference implementation's library default rather than stated by SHA-256/ECDSA/RFC 6979/DER/base64 alone. Roughly half of all signatures match without it, so an implementation that misses it appears to work most of the time. A written spec cannot catch that; generating vectors from the reference implementation and comparing real bytes can — see the Python package for how, and do the same for any further port.

Low-S normalisation follows language lines: @noble/curves normalises s, while python-ecdsa, Bouncy Castle (JVM and .NET) and Go's crypto/ecdsa do not — a port in any of the latter must normalise it explicitly.

All three ports have since deleted their own signing, now that Activeledger ships classical secp256k1 with RFC 6979 and low-S in its own SDKs — one byte-exact signer rather than several. A port that starts today should still generate its own vectors first, to confirm the SDK it depends on agrees with the reference before relying on it.

This document does not re-derive the business rules already covered by the TypeScript guide, The signing model, Swaps, and Treasury approval — it specifies the exact bytes those flows depend on.

Contents​

  1. Canonical JSON serialization — the #1 reimplementation blocker
  2. Signing pipeline — two primitives; the default is post-quantum
  3. Transaction shapes
  4. Amount encoding
  5. Network codes
  6. Error taxonomy
  7. Idempotency and retry
  8. Transport
  9. Versioning
  10. Open issues for a clean port

1. Canonical JSON serialization​

Every signature in this system — a ledger transaction's, and a resting order's — is computed over the JSON serialization of a plain object, not over a hash of individually-typed fields. That means the exact bytes JSON.stringify produces from a particular V8 object are the signed message. A port must reproduce those bytes exactly, or its signatures will never verify.

1.1 What the code actually does​

Two call sites, both in this package, both reduce to the same rule:

  • signPayload(payload, privateKeyHex) — the dispatcher every signing site actually calls (packages/chain-client/src/pq-keys.ts:170-176) — builds its message with typeof payload === 'string' ? payload : JSON.stringify(payload) (pq-keys.ts:173) and then hands it to whichever primitive the key's type selects. The classical branch, signPayloadBase64 (packages/chain-client/src/manual-sign.ts:25-27), repeats the identical line and then hashes: const hash = sha256(new TextEncoder().encode(message));. Both primitives construct the message the same way — this section's rules are common to all three key types — but what happens to those bytes afterwards differs, and §2.3 is where.
  • computeOrderHash(order) — packages/chain-client/src/varnir-client.ts:1958-1960 — sha256(new TextEncoder().encode(JSON.stringify(order))), hex-encoded.

So the rule is: JSON.stringify(value) using V8's default (no replacer, no space), UTF-8 encoded — then SHA-256'd for the classical signing path and for orderHash, and fed to the signature scheme unhashed for the post-quantum path (§2.3). Concretely, a conforming implementation must reproduce:

  1. Key order = insertion (declaration) order, not alphabetical, not schema-declared. V8's JSON.stringify walks a plain object's own enumerable string keys in the ECMAScript [[OwnPropertyKeys]] order: integer-like keys first (ascending numeric), then all other string keys in the order they were created on the object. None of the objects signed in this codebase have integer-like keys, so in every case that matters here, the rule reduces to plain insertion order — but a port's JSON encoder must be told to preserve insertion order (e.g. json.dumps in Python 3.7+ over a dict already does; encoding/json in Go does not — it sorts struct fields... actually alphabetically by default via reflection order/tags, which will NOT match; a Go port must build an ordered structure, e.g. []KV pairs or a manually-ordered encoder). See §1.4 for the exact field order of every signed object in this system — a port should treat that table as the literal contract, not derive it from "whatever order feels natural."
  2. No whitespace. No space after : or ,, no newlines, no indentation — this is JSON.stringify(value)'s default compact form.
  3. Numbers are emitted as JSON numbers, using JS's number-to-string algorithm, when the field is a number in the source object. Only two kinds of numeric fields appear in anything signed by this SDK: $expire (ms-since-epoch, e.g. 1893456000000) and order.expire (same shape). Both are safe (under 2^53) integers, so their JSON form is an unambiguous plain integer literal with no fractional part, no exponent, and no sign. A port must serialize these as bare integers (1893456000000, not 1893456000000.0, not 1.893456e12) — this is the one place a naive "use my language's generic number formatter" will bite: many languages' default float formatter adds a trailing .0 for whole numbers.
  4. Everything else is a JSON string. Every amount, hash, salt, stream id, network code, and hex key in a signed payload is a decimal-string or hex-string — never a JSON number — specifically so no language's float/bigint boundary is ever in the loop. See §4.
  5. String escaping: only ", \, and control characters < 0x20 are escaped (as \", \\, \n/\t/etc. or \u00XX); every other Unicode code point, including anything above ASCII, is emitted literally as UTF-8 bytes. In practice every string ever signed here (identities, hex, network codes, salts) is ASCII, so this is currently moot, but a port must not default to an "ASCII-safe" JSON encoder that escapes non-ASCII as \uXXXX (a common default in some JSON libraries) if a human-readable field is ever added to a signed object later — that would silently break cross-language signature compatibility for exactly the payloads that contain it.
  6. $sigs (and, for onboard, $selfsign) are never part of the signed payload. Only the inner object actually passed to signPayload is signed — for ledger transactions that is tx.$tx, not the full envelope {$sigs, $tx} (send at varnir-client.ts:1605, transferOnLedger at :1783, signSwap at :1885, and every other site listed in §2.1: the signature is always computed on tx.$tx, then stored into tx.$sigs afterward — the container can't be self-referential). For resting orders, the signed value is the bare order object itself, not a {order, sig} wrapper (createOrder, varnir-client.ts:1940-1948).

1.2 Worked example​

Given this order object (a Varnir.Swap resting order, native SEP for native TRX-NILE), built exactly as createOrder constructs it (varnir-client.ts:1383-1390):

{
"maker": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678901234567890123456789ab",
"give": {"network": "SEP", "token": "#native", "amount": "10000000000000000"},
"want": {"network": "TRX-NILE", "token": "#native", "amount": "25000000"},
"expire": 1893456000000,
"salt": "0xdeadbeefcafef00dfeedfacecafebabe0123456789abcdef0123456789abcd",
"domain": {"namespace": "varnir", "contract": "a771fbeeaa4d3f6f5e2a1c9b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e"}
}

JSON.stringify(order) (the EXACT signed message — 422 bytes, no line breaks, reproduced verbatim below on one line):

{"maker":"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678901234567890123456789ab","give":{"network":"SEP","token":"#native","amount":"10000000000000000"},"want":{"network":"TRX-NILE","token":"#native","amount":"25000000"},"expire":1893456000000,"salt":"0xdeadbeefcafef00dfeedfacecafebabe0123456789abcdef0123456789abcd","domain":{"namespace":"varnir","contract":"a771fbeeaa4d3f6f5e2a1c9b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e"}}

SHA-256 of that UTF-8 byte string (32 bytes, hex-encoded below — this is also orderHash, per computeOrderHash):

ff0e9e5eace0e044d7facc156167d58274e3885a07e05b74fbd14d9a0804ca1c

A port's test vector must reproduce exactly this 64-character hex string for the order object above — any other value means the port's JSON serialization or hashing diverges from §1.1's rules somewhere. (This value was generated and printed programmatically with the repo's own @noble/hashes and @noble/curves — the same libraries manual-sign.ts uses — from a throwaway, non-secret test private key, purely to produce a byte-exact worked example; it is not a real identity or a real order.)

Test private key used (32 bytes, hex, not derived from any real recovery phrase — throwaway): 4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362400 (64 hex characters / 32 bytes; pinned by packages/chain-client/test/core-contract-golden-vector.test.ts). Its compressed public key (publicKeyForPrivateKey, defined in pq-keys.ts:132-141 and re-exported from varnir-client.ts:950): 0x032e42428f3aaa3f39238ce79605e61b2926e0ed83af5434086507b7f854c08082

secp256k1.sign(hash, privateKey) (deterministic ECDSA per RFC 6978, via @noble/curves), DER-encoded, then base64'd (manual-sign.ts:26-33):

  • DER hex: 304402205bc3fe910123763d2b535b1ff6b3c6cf7d14fa73144eb8b0117dacb0bf055d860220387af109a032fc792fd4c6cbc2f5ad6b64bb608201a435f52b8352a420614038
  • sig (base64, this is the SignedOrder.sig value): MEQCIFvD/pEBI3Y9K1NbH/azxs99FPpzFE64sBF9rLC/BV2GAiA4evEJoDL8eS/UxsvC9a1rZLtgggGkNfUrg1KkIGFAOA==

A port's test suite should hardcode this exact order object, private key, hash, and signature as a golden vector, and additionally verify with the public key above via secp256k1.verify (manual-sign.ts:46-57 is the mirror check) — round-tripping through your own implementation of steps 1-4 must reproduce every one of these values byte-for-byte.

This vector is specific to a secp256k1 key, which is no longer the default key type (§2). The message bytes and the SHA-256 above are the same whatever key signs them, but the signature line cannot be reproduced by a Falcon-512 or ML-DSA-65 key — those signatures are randomised and are not pinnable at all. §2.7 is the post-quantum counterpart of this section, and §2.5 explains exactly which values a port may and may not assert on.

1.3 Comparison to RFC 8785 (JCS)​

RFC 8785 (JSON Canonicalization Scheme) is the closest existing named standard for "canonical JSON to sign." Current Varnir behaviour does not conform to it, in one load-bearing way:

Current (JSON.stringify)JCS (RFC 8785)
Key orderInsertion/declaration orderLexicographic (UTF-16 code unit) sort, always
NumbersJS ToString(Number)ECMAScript-compatible ToString, same algorithm — no divergence for the integers actually used here
WhitespaceNone (JSON.stringify default)None
String escapingV8 default (see §1.1.5)Same minimal escaping (RFC 8785 §3.2.2)

The key-order divergence is the one that matters, and it is a genuine design choice already baked into a deployed, authoritative contract: the Varnir.Swap contract re-derives orderHash and re-verifies signatures by JSON.stringify-ing the order object it was handed after an on-ledger JSON round-trip (packages/chain-client/src/varnir-client.ts:1378-1380's own comment: "the contract re-stringifies this exact object (after a JSON round-trip, which preserves key order) to verify the signature and recompute the hash, so it must not be reordered"). That is: the contract's own correctness today depends on insertion order being preserved end-to-end, not on a canonical (sorted) form. Switching to JCS would be a breaking protocol change requiring the contract, every existing signer, and every port to move in lockstep.

Recommendation: do not adopt JCS for the current signature scheme. Instead, treat this document's §1.4 field-order table as the actual specification — a fixed, enumerated field order per transaction/order type, which is exactly as reproducible as JCS's sort rule and requires zero contract changes. Reserve JCS (or a similar canonicalization) for a deliberate, versioned v2 signing scheme (see §9) that a port and the contract adopt together — at that point JCS's "sort solves it forever, including future fields" property becomes worth the migration cost. Flag this now as the single highest-risk item for any port: get the field order wrong on any one transaction type and every signature that type produces will be rejected as "Maker order signature invalid" / "Order A signature invalid" (apps/contracts/testnet/Varnir.Swap.ts:378, :662) or the ledger's generic signature-check failure for $tx, with no more specific diagnostic than that.

1.4 Field order per signed object​

This is the literal contract. Each row is the exact key sequence, at every nesting level, in the object actually passed to the signing function — reproduce it verbatim; do not alphabetize, do not add a field, do not carry a field forward from another operation's shape.

Signed objectRoot key orderSource
Resting order (createOrder)maker, give{network,token,amount}, want{network,token,amount}, expire, salt, domain{namespace,contract}varnir-client.ts:1383-1390
send() tx $tx$contract, $namespace, $entry, $i{owner{$stream,network,token,amount,to}}, $r{}, $expire ($expire appended last by stampExpiry after the literal is built)varnir-client.ts:1128-1156
transferOnLedger() tx $tx$contract, $namespace, $entry, $i{owner{$stream,network,token,amount}}, $o{to{$stream}}, $expirevarnir-client.ts:1226-1248
buildSwap() tx $tx$contract, $namespace, $entry, $i{maker{$stream,network,token,amount}, taker{$stream,network,token,amount}}, $expirevarnir-client.ts:1281-1291
fillOrder() tx $tx$contract, $namespace, $entry, $i{taker{$stream,network,token,amount[,minReceive]}}, $o{maker{$stream,order,sig}}, $expirevarnir-client.ts:1495-1509
matchOrders() tx $tx$contract, $namespace, $entry, $i{submitter{$stream}}, $o{a{$stream,order,sig}, b{$stream,order,sig}}, $expirevarnir-client.ts:1560-1578
cancelOrder() tx $tx$contract, $namespace, $entry, $i{maker{$stream,orderHash}}, $expirevarnir-client.ts:1601-1612
confirmTransfer() tx $tx$contract, $namespace, $entry, $i{owner{$stream,network,token,amount,to}}, $o{approved{$stream}}, $r{}, $expirevarnir-client.ts:1972-2005
onboard() tx $tx (self-signed, no $entry)$contract, $namespace, $i{otk{publicKey,type}}, $expirevarnir-client.ts:803-816
Key-change tx $tx (addKey/threshold, no $entry)$contract, $namespace, $i{owner{$stream, ...roleInput}}, $expirevarnir-client.ts:2051-2067

Two structural notes that a port must not miss:

  • $expire is always appended last, because stampExpiry (varnir-client.ts:106-108) mutates the already-built $tx object by assignment (txBody.$expire = ...), and a new property assigned to an existing plain object is appended after all pre-existing keys, in every JS engine. It is never written inline in the object literal. Put it last in a port's own transaction builder, unconditionally.
  • onboard and the key-change helper omit $entry entirely — that field is not present at all in those two shapes, not merely empty. Do not send $entry: null or $entry: ''; omit the key.
  • Optional fields (to on send, minReceive on fillOrder's taker input) are only present in the object — and therefore only present in the signed JSON — when the caller actually supplied them (varnir-client.ts:1486-1494's explicit comment: built as a separate field so "an omitted minReceive leaves that object... identical to before this option existed"). A port must literally omit the key, not include it with a null/empty value, or its serialization will not match a peer that omitted it, and the two will disagree on $tx bytes for what should be the identical transaction.

2. Signing pipeline​

There are two signature primitives, not one, and the default for a new identity is the post-quantum one. Which primitive signs a given payload is decided entirely by the key type, which is inferred from the private key string itself (§2.1) — never passed as an argument, never configured. A port that implements only the classical ECDSA path cannot onboard an identity the way this SDK does by default, and cannot sign for any identity created with the default key type since the Activeledger 4.7 upgrade.

typePrimitiveDefault for a new identity?Signed through
falcon-512Falcon-512Yesthe Activeledger SDK's own WebCryptoProvider → @noble/post-quantum's falcon512 (§2.3)
ml-dsa-65ML-DSA-65 (FIPS 204)No — the other supported post-quantum option, same code paththe same provider → @noble/post-quantum's ml_dsa65 (§2.3)
secp256k1ECDSA over secp256k1, DER-encodedNo — the classical fallback, and what every pre-4.7 identity holdsmanual-sign.ts (§2.2)

Those three literals are the whole type universe (packages/chain-client/src/pq-keys.ts:53 — the module this section cites as pq-keys.ts throughout), and they are the exact strings Activeledger 4.7 stores on an authority and dispatches its own verification on — a port must emit them verbatim, not a variant spelling like falcon512 or dilithium3. The default is declared twice, in agreement: DEFAULT_KEY_TYPE (pq-keys.ts:59) and VarnirClient.generateKey's own parameter default (varnir-client.ts:1198), both 'falcon-512'.

The identity's type decides nothing about a key added after it, either: every authority carries its own type, so a Falcon-512 identity can hold a secp256k1 key and a secp256k1 identity can hold a Falcon-512 one. A port must read the type off the authority, not off the identity.

Two signing domains must never be confused (see signing.md for the REST domain, which a chain-transaction port does not need):

  1. Ledger transaction signing (what this document is mostly about): the message is tx.$tx (or the bare order, for a resting order). The key is the identity's own onboarding key, or a delegated key with sufficient stake (see §6/Treasury).
  2. REST request signing (buildApiKeyAuthHeaders, manual-sign.ts:82-96): message is ${routeTag}\n${timestamp}\n${bodyText}, signed with whichever key the caller chose — the identity's own onboarding key, or any other authority on that identity (typically an addKey({role: 'offledger', scopes}) one). A pure chain-transaction port (Go/Rust node client) can ignore this domain; an SDK that also talks to Varnir's HTTP API (invoices, webhooks) needs it. It signs through the same dispatcher (manual-sign.ts:90), so it is post-quantum-capable: an identity's own Falcon key can sign the identity-owner routes, and the public key travels in the X-Varnir-Api-Key header in its tagged form (manual-sign.ts:70-80, asserted by test/pq-header-auth.test.ts:79-86) — the one place on any wire where the tag is not stripped. See §2.4.

2.1 One entry point, two primitives​

signPayload(payload, privateKeyHex) (pq-keys.ts:170-176) is the single function every signing site in the client calls — onboard (varnir-client.ts:1120), createWallet (:1278), send (:1605), transferOnLedger (:1783), signSwap (:1885), createOrder (:1948), fillOrder (:2067), matchOrders (:2136), cancelOrder (:2170), confirmTransfer (:2620) and the key-change helper (:2680). It does three things, in order:

  1. keyTypeOf(privateKeyHex) (pq-keys.ts:88-93): if the string starts with falcon-512: or ml-dsa-65:, that is the type; anything else is secp256k1. The type is carried in the key string, so there is no separate type argument to get wrong, and every pre-existing 0x-hex key keeps resolving classically with no migration.
  2. Build the message: typeof payload === 'string' ? payload : JSON.stringify(payload) (pq-keys.ts:173) — §1's rules, identically for every type.
  3. Dispatch: secp256k1 → signPayloadBase64 (pq-keys.ts:172, §2.2); post-quantum → provider.sign(message, {pkcs8pem: secret}, type) (pq-keys.ts:175, §2.3).

verifyPayload(payload, signatureBase64, publicKeyHex) (pq-keys.ts:184-199) is the mirror, dispatching on the public key's tag instead, and it fails closed: any throw from a malformed key string is caught and returned as false (pq-keys.ts:190-198), because this function sits on an authentication path where an uncaught exception would surface as a 500 rather than a clean rejection. A port must reproduce that: an exception is never a valid auth result.

These are genuinely two code paths, not one algorithm with a parameter. A port should build them as such — a sign(message, key) -> bytes interface with two implementations selected by the key's tag — rather than trying to unify them, because they differ in whether the message is pre-hashed, in signature encoding, in key encoding, and in determinism.

2.2 The classical primitive (secp256k1)​

SHA-256 over the canonical JSON message (§1), signed with ECDSA over secp256k1, DER-encoded, base64-encoded. Steps, precisely (manual-sign.ts:25-35):

message = JSON.stringify(payload) // §1
hash = SHA256(UTF8(message)) // 32 bytes
sig = ECDSA_sign_secp256k1(hash, privateKey) // RFC 6979 deterministic k
der = DER_encode(sig) // ASN.1 SEQUENCE{INTEGER r, INTEGER s}
result = base64(der)
  • The private key hex may be 0x-prefixed or bare (manual-sign.ts:28 strips a leading 0x); a port should accept both for interop but is free to always emit one canonical form itself.
  • Verification (manual-sign.ts:46-57) is the mirror: same hash, secp256k1.verify(derBytes, hash, pubkeyBytes) against the compressed (33-byte) public key. signing.md's note that only the compressed form is supported applies here too — publicKeyForPrivateKey's classical branch (pq-keys.ts:134-135, re-exported from varnir-client.ts:950) always passes true to secp256k1.getPublicKey.
  • ECDSA signing here is deterministic (@noble/curves's default secp256k1.sign uses RFC 6979 nonce derivation) — the same (message, privateKey) pair always produces the same signature. A port using a non-deterministic ECDSA implementation (raw OpenSSL with a random k, for instance) will still produce valid signatures (the contract only verifies, it doesn't compare to an expected byte string), but will not reproduce this document's golden vector byte-for-byte and cannot be used to differentially test against it beyond verify. Recommend RFC 6979 deterministic signing in every port for testability and byte-for-byte parity with the TS client's own test suite. This determinism does not carry over to the post-quantum types — see §2.5.

2.3 The post-quantum primitive (Falcon-512 and ML-DSA-65)​

Both post-quantum types share one code path. signPayload delegates to a single shared WebCryptoProvider instance (pq-keys.ts:66) from @activeledger/sdk-web (pinned ^2.1.1, resolved 2.1.1) — deliberately the SDK's own provider rather than a re-derived encoding, because that is the exact code whose output the 4.7 nodes verify (pq-keys.ts:11-17). That provider is a thin dispatcher over @noble/post-quantum 0.7.1 (falcon512 from @noble/post-quantum/falcon.js, ml_dsa65 from @noble/post-quantum/ml-dsa.js — sdk-web/lib/crypto.js:2-3, :50-53; the same two imports are mirrored in pq-keys.ts:43-44, :225-228, for the recovery derivation).

Steps, precisely (sdk-web/lib/crypto.js:73-86, reached via pq-keys.ts:170-176):

message = JSON.stringify(payload) // §1, identical to §2.2
secret = base64_decode(<secret half of the tagged private key>)
entropy = 48 random bytes (falcon-512) / 32 (ml-dsa-65) // crypto.getRandomValues
sig = SCHEME.sign(UTF8(message), secret, {extraEntropy: entropy})
result = base64(sig)

Four differences from §2.2 that a port must get right:

  1. No pre-hash. The classical path SHA-256s the message and signs the 32-byte digest. The post-quantum path signs the raw UTF-8 message bytes and lets the scheme do its own internal hashing (crypto.js:78 — pq.sign(textEncoder.encode(data), …), the message, not a digest). Feeding a SHA-256 digest to falcon512.sign produces a perfectly valid signature over the wrong message, which no Varnir node will accept and which fails with nothing more specific than a signature error.
  2. Fresh entropy on every signature (crypto.js:76-77), which is what makes these signatures non-reproducible — §2.5.
  3. Key material is base64 of raw bytes, carried in a field named pkcs8pem. That name is a classical-key holdover; for a post-quantum key it is not PEM, not PKCS#8, and has no armour lines — it is a plain base64 string of the raw key bytes (pq-keys.ts:28-31). The secret half is extracted from the tagged private key string by postQuantumSecret (pq-keys.ts:96-105), which throws if the string does not carry both halves.
  4. The base64 dialect is standard RFC 4648 — the A-Za-z0-9+/ alphabet, = padding on encode, padding stripped and any non-alphabet character skipped on decode (crypto.js:18-49; pq-keys.ts:209-222 replicates the encoder byte-for-byte so a phrase-derived key is indistinguishable from a generated one). Not URL-safe base64, and never line-wrapped.

Verification is SCHEME.verify(sigBytes, UTF8(message), publicKeyBytes) (crypto.js:87-96) — note the signature-first argument order, which is @noble/post-quantum's convention and the reverse of what several other libraries use.

Key generation for a brand-new key (generateKeyPair, pq-keys.ts:112-124) calls provider.generate(true, type), which draws scheme.lengths.seed random bytes and calls scheme.keygen(seed) (crypto.js:55-64). Keygen is deterministic in that seed — which is the whole basis of recovery (§2.6) — even though signing is not.

Sizes a port needs in order to allocate and validate buffers. Every number below was measured by running the repo's own pinned @noble/post-quantum 0.7.1, not read from a spec:

Falcon-512ML-DSA-65
keygen seed48 bytes32 bytes
public key897 bytes1952 bytes
secret key1281 bytes4032 bytes
signaturevariable — 648–663 bytes observed over 2000 signatures; the library declares no fixed signature length for Falcon at all3309 bytes, fixed (and declared as such)
public key, base641196 chars2604 chars
secret key, base641708 chars5376 chars
tagged public key string (<type>:<b64 pk>)1207 chars2614 chars
tagged private key string (<type>:<b64 sk>:<b64 pk>)2916 chars7991 chars
signature, base64 (what $sigs carries)864–884 chars, varying per signature4412 chars

Falcon-512's variable signature length is a real portability hazard: a port must treat a Falcon signature as a length-prefixed or length-discovered byte string, never a fixed-size buffer, and must not hardcode the observed 663-byte maximum either — that is an empirical maximum over 2000 samples of one implementation, not a bound. ML-DSA-65 is fixed-length and safe to size statically.

2.4 Public keys on the wire, and the identity​

A post-quantum key exists in two string forms, and using the wrong one in the wrong place produces a different identity rather than an error (pq-keys.ts:21-39 is the authoritative comment):

FormShapeWhere it is used
Taggedfalcon-512:<b64 pk> / ml-dsa-65:<b64 pk>; private keys are <type>:<b64 sk>:<b64 pk>Everything the SDK hands a caller or accepts back: KeyPairHex.publicKeyHex, VarnirClient's privateKeyHex option, and the X-Varnir-Api-Key request header (§2, domain 2)
Untagged (on the wire)the bare base64 public key, with the type moved into a sibling type fieldThe onboard $i input otk: {publicKey, type} (varnir-client.ts:1293), the authority record addKey writes, {public: publicKey, type} (varnir-client.ts:3094), and the string that is hashed to name the identity
secp256k10x-prefixed compressed hexBoth forms are the same string; onWirePublicKey returns it unchanged (pq-keys.ts:152), so classical identity computation is byte-for-byte what it always was

onWirePublicKey(key) (pq-keys.ts:150-160) is the single converter, and it accepts a tagged private key, a tagged public key, or a plain secp256k1 key — splitting on : and taking parts[1] for a private string (which has both halves) or parts[0] for a public one. Base64 never contains :, which is what makes the tag separator unambiguous (pq-keys.ts:30-31).

The identity id is then:

identity = hex(SHA256(UTF8(untaggedPublicKey + "identity")))

varnir-client.ts:1074-1077, with the literal stream name "identity" at varnir-client.ts:79 — it must match the string Varnir.Onboard passes to newActivityStream, so it is part of the wire contract, not an implementation detail. computeIdentity calls onWirePublicKey itself, so passing either form gives the same answer; a port that hashes the tagged string directly gets a valid-looking 64-hex id that belongs to no ledger stream. Because this is a pure local computation, onboarding is idempotent and recovery needs no network call to find out which identity a phrase owns (varnir-client.ts:1086-1096).

2.5 Determinism, and what a port can pin​

Post-quantum signatures in this system are NOT deterministic. This is not an incidental property of the library's defaults — the provider explicitly draws fresh entropy from crypto.getRandomValues on every single call and passes it as extraEntropy (sdk-web/lib/crypto.js:76-80). Verified directly against the pinned libraries: signing one identical message with one identical Falcon-512 key ten times produced ten distinct signatures (and Falcon's varied in length as well as content); the same test on ML-DSA-65 produced five distinct 4412-character signatures. @noble/post-quantum's own default with no options passed is randomised too, so there is no "just omit extraEntropy" escape hatch — and in any case the provider never omits it. The repo already records this in test/errors-idempotency-reserved.test.ts:33-42, which pins the byte-identical-replay assertions to an explicitly-secp256k1 key for exactly this reason.

The consequence for a port's test strategy is concrete:

  • A port must not pin a post-quantum signature as a golden vector. §1.2's approach — hardcode the signature bytes and assert equality — is available for secp256k1 only. Chasing a fixed Falcon signature is chasing something that does not exist.
  • What is pinnable, and should be pinned: the canonical JSON message bytes and their SHA-256 (§1, key-type-independent); the tagged key strings derived from a fixed recovery phrase; the key lengths, and ML-DSA-65's fixed signature length, from the table in §2.3 (Falcon's signature length is variable — see the warning there, and do not assert on it at all); and above all the identity id a phrase resolves to, which is a 64-hex string and fully deterministic (§2.7).
  • The only meaningful signature-level test is a round-trip: sign, then verify with the corresponding public key and assert true; sign the same message twice and assert the two signatures differ (a negative control that catches an implementation that accidentally fixed its entropy); and verify a tampered message and assert false (a control that catches a verifier stubbed to return true).
  • Cross-implementation differential testing works in the verify direction only. A port cannot compare its Falcon signature bytes to the TS client's, but it can sign with its own implementation and verify with the TS client's verifyPayload, and vice versa. That is the strongest available equivalence proof, and it is sufficient: the ledger only ever verifies.

This also sharpens §7's retry rule. §7 already requires that a retry resend the identical already-signed bytes rather than rebuild and re-sign; with a post-quantum key that is doubly load-bearing, because re-signing an otherwise-identical $tx now yields different $sigs bytes even when $expire is unchanged. Whether the ledger's umid is computed over $tx alone (same umid, still deduped) or over the whole envelope including $sigs (different umid, executed twice) is decided by ActiveLedger core, which is not in this repository and could not be confirmed here — see §10. A port must not depend on either answer.

2.6 Recovery phrases​

The 12-word recovery phrase is shared machinery with a scheme-specific tail: one phrase format, one seed step, then two different derivations.

Common to every key type (packages/signing/src/index.ts):

  • BIP39 English wordlist, 128 bits of entropy, therefore exactly 12 words (:23, :25, :55-57), with RECOVERY_PHRASE_LENGTH = 12 exported as the literal (:27).
  • Normalization before anything else: trim, lowercase, split on any whitespace run, re-join with single spaces (:51-53). A port must normalize identically or a phrase typed with a double space derives a different key.
  • The seed step: validate the mnemonic, then bip39.mnemonicToSeedSync(normalized) → 64 bytes (:78-84). No BIP39 passphrase (the "25th word") is used or accepted anywhere; a port must pass an empty passphrase. This is deliberately exported as recoverySeedFromPhrase so the post-quantum derivation reuses this exact step rather than duplicating it.

Then the two tails diverge:

secp256k1falcon-512 / ml-dsa-65
FunctionderivePrivateKeyFromPhrase (signing/src/index.ts)deriveKeyPairFromPhrase (pq-keys.ts)
DerivationHMAC-SHA512("Bitcoin seed", seed)[0..32] — the BIP-32 master scalarHKDF-SHA512, info activeledger-seed-v1:<type>, then the scheme's keygen
Output0x-hex private + compressed publicthe tagged <type>:<b64 sk>:<b64 pk> string

Neither derivation is Varnir's. Both are upstream's, and upstream uses two constructions rather than one: activeledger-seed-v1 is the post-quantum info string, and there is no HKDF step on the classical path to look for — secp phrases predate the post-quantum types, so upstream kept the derivation those phrases already had.

Both derivations are upstream's own, so a port calls them rather than reimplementing either. A port must not add a step of its own: a derivation that differs from upstream by even one step produces a different key and a different identity from the same twelve words, with nothing throwing.

No legacy derivation is supported

Before 2026-09-21, Varnir used two schemes of its own instead of upstream's: the BIP-44 child at m/44'/1'/0'/0/0 for secp256k1, and HKDF-SHA256 with info varnir/pq-keygen/v1:<type> for the post-quantum types. Both are retired, with no legacy reader kept anywhere. Use only the two derivations in the table above. A words-only backup issued before that date opens a different identity now; anyone holding the private key directly is unaffected, and can import it directly.

Three details a port will otherwise get wrong:

  • The salt is omitted on the post-quantum path. RFC 5869 §2.2 defines an absent salt as a HashLen run of zeros; for HMAC that is indistinguishable from a zero-length salt, since both zero-pad to the block size. A port may pass absent, zero-filled or empty, and must pass nothing else.
  • info is the domain separation, and it contains the type. The same phrase therefore yields a different key — and a different identity — for falcon-512 than for ml-dsa-65.
  • The two paths are not interchangeable. deriveKeyPairFromPhrase refuses 'secp256k1' and points at the classical function. One phrase owns up to three distinct identities, one per scheme, and a restore flow has to decide which: prefer the Falcon identity if it exists on the ledger, fall back to secp256k1 if only that does, and default to Falcon when neither has been onboarded.

The classical half of this API is re-exported for consumers at the @varnir/chain-client/keys subpath (src/keys.ts:12-20) — a subpath specifically so the main entry point does not drag in bip39 and its full English wordlist.

2.7 Worked example: a post-quantum identity​

Everything here is reproducible, because key derivation is deterministic even though signing is not. The phrase is the canonical BIP39 all-zeros test mnemonic — a public test vector, never a real wallet — and it is the same M1 the repo's own recovery test pins (packages/chain-client/test/pq-recovery.test.ts:26, :30-34):

abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about

Derived with deriveKeyPairFromPhrase(phrase, type) and named with computeIdentity (§2.4):

falcon-512 (default)ml-dsa-65secp256k1
identity71d634437de4e5baccbdafa242d275e19886322493b2cb082d55966c95c14f5d4fbb4b128e8a12f1572196c4a94148c87d7c241b3df7328fec8d3dd46c5921fce2fdb1930d5d880ba71c26049004762ea4e49856b2e43e50ebb9b4b49640098f
tagged public keyfalcon-512:CYvkdYQFRJlbWvFTp…yTHBeXJpeG (1207 chars)ml-dsa-65:mTRJc+nwjgcfTLrOzm…s8DeMGEdE= (2614 chars)0x03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494
SHA-256 of the tagged public key string3339d9c275c4b2dfa31bd1081948ebd2f4f2b2b89d863044d9299d6cbfddc99cc3607be604d510d1e2d38e71b6f8e76b2db93e9a94e8d77fe56b2435dee63176—
SHA-256 of the untagged (on-wire) public key stringefc19d3d6f16a0554ab848063ca6b4ef576e3e97461d57d96ee3ab9d8753e680b3bd18891a3b86d26754481282710bec2c0cedf11ef7d7d4ae6eadc4318e6980—
private key2916 chars, falcon-512:<1708 b64>:<1196 b64>7991 chars, ml-dsa-65:<5376 b64>:<2604 b64>0x1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67

Every value above was recomputed when the derivations changed on 2026-09-21, not adjusted. A port that reproduces the secp256k1 row is agreeing with Activeledger's own Go SDK, which is where that key was independently checked.

The two post-quantum keys are too long to print in full here, so the SHA-256 rows are the pinnable substitute: a port that derives the same key will hash to the same digest, and the head/tail fragments above let a human eyeball a mismatch. The three identity ids are the primary vector — a port's recovery test must reproduce all three from that one phrase, and the fact that they differ is itself the proof that the info domain separation (§2.6) is implemented.

The signing round-trip, using §1.2's order object as the message so the two worked examples line up:

  • The message is byte-identical to §1.2's: 422 bytes, SHA-256 ff0e9e5eace0e044d7facc156167d58274e3885a07e05b74fbd14d9a0804ca1c. The message does not depend on the key type — only on §1.
  • Signing it ten times with the Falcon-512 key above produced ten different signatures, 872–880 base64 characters, every one of which verifyPayload accepted under that key's tagged public key.
  • The same signatures, checked against the order with expire changed by one millisecond, were all rejected.

So the port-side test is:

sig1 = sign(order, falconPrivateKey)
sig2 = sign(order, falconPrivateKey)
assert verify(order, sig1, falconPublicKey) == true
assert verify(order, sig2, falconPublicKey) == true
assert sig1 != sig2 // randomised, per 2.5
assert verify(tamper(order), sig1, falconPublicKey) == false
assert sha256(canonical_json(order)) == "ff0e9e5e…04ca1c" // pinnable, per 1.2
assert identity(derive(PHRASE, "falcon-512")) == "71d63443…c95c14f5d"

with no assertion anywhere on the signature bytes themselves.

2.8 How $sigs is keyed​

$sigs is a map from the ledger stream id being signed for to that signature — not from a role name. varnir-client.ts:2676-2677's comment on submitKeyChange is explicit about this: "Keyed by the stream id being signed for, not by the $i object key — keying it 'owner' fails with 'Input Signature Incorrect'." The one exception is onboard, which — because no identity exists yet to key by — uses the literal string 'otk' (varnir-client.ts:1120), matching the $i key it set at :1111. This is identical for every key type; only the signature value differs.


3. Transaction shapes​

Every operation below builds a JSON object per §1.4, signs it per §2, then POSTs the envelope {$sigs: {...}, $tx: {...}} (multi-input flows like buildSwap/signSwap build the envelope once and add to $sigs as each party signs) to a chain node via sendToAnyNode (§8). $i inputs require a matching entry in $sigs; $o outputs do not — that asymmetry is what lets a maker's or counterparty's already-signed order ride along in someone else's transaction (varnir-client.ts:1501-1506, :1566-1574, :1997-2001) without needing them to be online to co-sign the transaction itself (they already signed the order, off-ledger, earlier).

OperationContract entry$i (needs a matching $sigs entry)$o (no signature needed here)Client method
OnboardVarnir.Onboard (no $entry)otk{publicKey,type}—VarnirClient.onboard — varnir-client.ts:791-832
Withdraw to L1Varnir.CryptoTransfer signowner{$stream,network,token,amount,to}— ($r:{} present, unused by the signature, reserved for gateway wallet selection)send — varnir-client.ts:1107-1181
L2-to-L2 transferVarnir.CryptoTransfer transferowner{$stream,network,token,amount}to{$stream}transferOnLedger — varnir-client.ts:1205-1256
Confirm treasury proposalVarnir.CryptoTransfer sign (fields overwritten by the contract from the stored proposal)owner{$stream,network,token,amount,to} (sent but replaced server-side)approved{$stream}confirmTransfer — varnir-client.ts:1939-2019
Co-signed swapVarnir.Swap swapmaker{$stream,network,token,amount}, taker{$stream,network,token,amount} (both must sign)—buildSwap/signSwap/submitSwap — varnir-client.ts:1269-1354
Fill a resting orderVarnir.Swap filltaker{$stream,network,token,amount[,minReceive]}maker{$stream,order,sig} (the maker's off-ledger-signed order)fillOrder — varnir-client.ts:1435-1517
Auto-match two resting ordersVarnir.Swap matchsubmitter{$stream} (authorises nothing but the submission)a{$stream,order,sig}, b{$stream,order,sig}matchOrders — varnir-client.ts:1531-1586
Cancel a resting orderVarnir.Swap cancelmaker{$stream,orderHash}—cancelOrder — varnir-client.ts:1594-1620
Add/change a keyVarnir.Keys (no $entry)owner{$stream, ...roleInput}—addKey/setApprovalThreshold — varnir-client.ts:2050-2135

Resting orders (createOrder) are the one thing in this table that is not itself a ledger transaction — it produces a SignedOrder (order, sig, orderHash) that only becomes a transaction later, when someone calls fillOrder or matchOrders on it. Its own signed object's shape is in §1.4's first row.

orderHash is hex(SHA256(JSON.stringify(order))) (varnir-client.ts:1401-1404) and is the client-side computation of the same value Varnir.Swap computes in-contract via ActiveCrypto's Hash.getHash(x, 'sha256') — confirmed equal by the same doc comment. It is the stable key the contract uses to mark an order consumed (filled or cancelled) on the maker's own stream state, so a port must produce the identical hash for cancelOrder/dedupe logic to address the same order the contract sees.

domain: {namespace, contract} inside a resting order (§1.4) is the replay-protection field: Varnir.Swap rejects a fill/match whose order domain does not match its own $namespace/$contract id (apps/contracts/testnet/Varnir.Swap.ts:385, :671, :674 — "not signed for this deployment (domain mismatch)"). The contract value is the bare contract id, i.e. this.transactions.$contract.substring(0,64) contract-side, matching swapContract (not contractRef(swapContract), which appends @1) client-side (varnir-client.ts:1381-1382's own comment). A port must sign the bare id here, not the id@version form used in $contract elsewhere.


4. Amount encoding​

There are exactly two representations, and the SDK boundary is where they switch:

  • Decimal string — what a caller passes and reads: "12.5", "0.01". This is the only form ever seen in a public method's amount parameter (SendOptions.amount, LedgerTransferOptions.amount, SwapLeg.amount, OrderLeg.amount — all typed string | number).
  • Raw smallest-unit integer, as a decimal string — what goes inside every signed payload (§1.4), and what MarketOrderResult (spentGive/receivedWant/unfilledGive, varnir-client.ts:489-498) and fillOrder's fillAmount/minReceive options (varnir-client.ts:1435-1494) both take and return. Never a JSON number — always a string, specifically so amounts up to 18+ decimal places of a token like ETH never pass through any language's float or even through a 64-bit integer type that might silently overflow (Tron/EVM balances routinely exceed 2^63). A port must use an arbitrary-precision integer type (Go's math/big.Int, Python's native int, Rust's a bigint crate) for every raw amount it touches, and must serialize/deserialize that type as a decimal string, never as a native JSON/language number.

4.1 toRawAmount — decimal string → raw integer​

packages/chain-client/src/networks.ts:318-330. Exact algorithm, in order:

  1. Trim the input; reject (throw) anything not matching ^\d*(\.\d*)?$, and reject the empty string or a bare ..
  2. Split on . into whole and fraction (fraction defaults to '').
  3. Reject (throw) if fraction.length > decimals — this never rounds; a caller supplying more precision than the asset supports gets an error, not silent truncation.
  4. raw = whole (or '0' if empty) + fraction padded on the right with zeros to decimals length, then strip any leading zeros (but never reduce it below a single "0").
  5. Reject (throw) if the result is "0" — a nonzero input that rounds to zero raw units is treated as an error, not a valid zero-amount send.

This is pure string arithmetic — the doc comment (networks.ts:307-312) explains why: Number(x) * 10 ** decimals silently corrupts the low-order digits of an 18-decimal amount (1.1 ETH → 1100000000000000128 was the observed failure mode this replaced). A port must implement this as string manipulation or arbitrary-precision decimal arithmetic — never as floating-point multiplication.

4.2 fromRawAmount — raw integer → decimal string​

networks.ts:333-339. Inverse of the above: left-pad raw to at least decimals + 1 digits with zeros, split at decimals from the right into whole/fraction, strip trailing zeros from the fraction, and join with . only if a fractional part remains. (If raw doesn't match ^\d+$, it is returned unchanged — a defensive passthrough, not a validated inverse.)

4.3 Where decimals come from​

Every call site resolves decimals from the on-ledger token registry (getToken/listTokens), never from a hardcoded table, for anything but each chain's native asset (whose nativeDecimals comes from static chain config, chainConfig in networks.ts:198-204). An unrecognised token address is refused outright — send, transferOnLedger, and resolveAssetLeg (used by swaps/orders) all throw rather than guess (varnir-client.ts:1119-1123, :1217-1221, :1325-1329). A port must replicate this refusal, not default to some assumed decimals (e.g. 18) for an unknown token — that is exactly the "misscaled amount" bug class this guard exists to prevent.


5. Network codes​

Three conventions coexist for the same chains (packages/network-codes/src/index.ts:9-24 has the authoritative description); a port only needs to know which one the signed payload uses:

FormExampleWhere usedDirection
A — ledger/credit codeSEP, TRX-NILE, tBNBStored at rest in layer2[network], the RAW token-registry stream, and every network field inside a signed transaction or order (§1.4)toLedgerCode
B — pool/pregen codesep, niles, tbnbVarnirChain values (SendOptions.wallet.chain, LedgerTransferOptions.chain, SwapLeg.chain, etc.) — what a caller passestoCanonical
C — provider chain idethereum-sepolia, tron-nileOff-wire, provider-facing onlyn/a

A port only ever needs to emit form A inside a signed payload. The SDK does this by resolving a caller-facing chain input (form B, or occasionally a raw ledger code) through chainConfig(chain).ledgerCode (networks.ts:198-204) before ever building a transaction — see every $i.owner.network/give.network/want.network assignment in §1.4, all of which are ledgerCode, not the caller's original chain string.

The shared table is @varnir/network-codes (packages/network-codes/src/index.ts), a zero-dependency package specifically so it can be required from both ESM/bundler consumers and the CommonJS/node10-resolution services (apps/minigate, apps/l1-relay) that can't import chain-client directly. A port should treat this table as the single source of truth to re-derive its own network-code map from, rather than hand-copying the NETWORKS/chain config elsewhere — the file's own top comment documents the exact bug class (silently dropped deposits) that resulted from every consumer keeping its own copy. Three functions matter:

  • toCanonical(network) → form B, lowercase, for display/compare.
  • toLedgerCode(network) → form A, mixed-case as the chain config declares it ('tBNB', not 'TBNB') — this is what belongs inside a signed payload.
  • toRegistryNetworkKey(network) → form A, but upper-cased ('TBNB') — the RAW token-registry stream's actual key convention, which is a real, documented, out-of-scope inconsistency with toLedgerCode's 'tBNB'. A port doing a registry lookup must upper-case; a port building a signed transaction must use toLedgerCode's exact casing. Do not conflate the two.

An unrecognised network code degrades to a lenient passthrough (trimmed-and-cased) rather than throwing, on purpose (network-codes/src/index.ts:78-80, :96-98) — matching the ad-hoc folds it replaced, so an unknown network is "no match," not a crash, on the deposit-detection path. A port's own table should extend this one rather than reject codes it doesn't recognise, unless it is deliberately being stricter than the reference implementation (in which case, document that divergence).

5.1 Bitcoin​

Bitcoin is the registry's fourth network family (family: "bitcoin"; the others are evm, tron and solana). The first entry is btc-testnet4 (ledger code btc-testnet4, nativeSymbol: "BTC", nativeDecimals: 8), disabled until the pool is enabled. What a port must do about it:

  • Decode the family. The registry loader must accept family: "bitcoin" and the entry's extra bitcoin object without failing, and expose the family for any registry network, enabled or not (networkFamily/NetworkFamily/Networks.family). The chain table (chains) is unchanged: it still lists only enabled testnet entries with a pregen pool, so Bitcoin appears there the day it is enabled and not before.
  • Amounts are satoshis, 8 dp. There is no new code: toRawAmount(x, 8) and fromRawAmount(x, 8) are the same algorithm as §4, and core.json pins '0.00000001', '21000000' and the rest at 8 decimals. Never a float, and '0.000000001' (9 dp) is refused, not rounded.
  • Validate addresses client-side. A port exposes decode, normalise and is-valid for Bitcoin addresses on btc, btc-testnet4 and btc-regtest (any other code is refused). It is a port of decodeAddress in packages/btc-core/src/address.ts: bech32 for witness v0 (20-byte P2WPKH, 32-byte P2WSH), bech32m for v1 with a 32-byte program (P2TR), base58check for P2PKH and P2SH; witness versions 2-16 and other v1 program lengths are refused; mixed case, bad checksum, surrounding whitespace, the wrong HRP and an address for another Bitcoin network are all refused with VALIDATION. Bech32 is normalised to lower case; base58 keeps its one form. The cases are bitcoin.json (addresses), generated from btc-core by generate-bitcoin.ts: each carries the accept/refuse answer, the canonical form, the type and the decoded hash, so a port cannot agree with itself and still pass.
  • send() returns the ledger umid, not a Bitcoin txid. A Bitcoin withdrawal is the §3 sign shape like any other L1 withdrawal (network is the ledger code, amount is the raw satoshi string). The Bitcoin txid does not exist until the gateway signs and broadcasts, and is read back later (getTransfer, the explorer link the registry carries). The destination is checked as §5.2 says.

5.2 Validating the destination in send()​

send() checks to before it does anything else with it. The order is fixed:

  1. Validate the format for the requested network. Refuse with VALIDATION and stop. Nothing has been looked up, signed or sent.
  2. Look up whether Varnir already knows the address (/wallets/by-address) and, if it does, settle on the ledger instead of L1 (§8, settlement).
  3. Sign and submit.

An invalid address must never reach the lookup or the ledger. The rule is chosen by the network's registry family (networkFamily), so a Tron address sent on an EVM network is refused as readily as a typo:

familyto is valid when
evm0x plus 40 hex digits; if the hex mixes upper and lower case, the case must be the address's EIP-55 checksum (all-lower or all-upper carries no checksum and passes)
trona 34-character base58check address whose payload is 21 bytes starting 0x41 (a T… address). The 42-hex 41… form is not a send destination
bitcoinaccepted by btc-core's decodeAddress for the network (§5.1)

Any other family is not checked. A 64-hex Varnir identity id is not an L1 address: it is accepted under settlement auto or ledger (it is a ledger target and is never looked up), and refused under l1.

EIP-55 needs Keccak-256 with its original padding (Ethereum's), not NIST SHA-3: sha3-256 in a standard library is a different function. Every port carries a Keccak-256 (BouncyCastle, pointycastle or a short sponge) and is pinned to the reference by addresses.json, which also has the known answers.

The vectors (addresses.json, generate-addresses.ts) are generated from the reference's l1AddressProblem: valid addresses, bad checksums, a wrong network or HRP, the wrong family for the network, mainnet on testnet4, an identity id and the empty string. The generator refuses to write if the reference disagrees with the answer a case is meant to have, so a reference bug cannot be blessed by regenerating. Each port's test also drives send() with every invalid case under all three settlements and checks that no request was made.


6. Error taxonomy​

The enum has shipped. VarnirErrorCode (packages/chain-client/src/errors.ts, VarnirErrorCode) is the stable taxonomy, and VarnirError carries it as .code alongside an unchanged .message, so branching on the failure kind no longer means matching English strings. classifyRejectionMessage (errors.ts:145-298) is the message-to-code mapping this table documents; it is the authority, and a divergence between the two is a bug in this table.

Underneath, a failure still arrives as either a LedgerRejection (contract-level "no", with only a free-text message — send-to-any-node.ts:34-39) or a client-side fail-fast thrown before a transaction is ever built. Every port should converge on the same codes. The table below covers the codes every port shares; the TypeScript reference also has IDENTITY_RECOVERED and the five new-recipient hold codes, listed after it.

codeMeaningConcrete message(s) it maps fromRetryable?
INSUFFICIENT_BALANCEThe signer's L2 balance can't cover the debit`Part-Balance to low : ${bal} < ${needed}` (Varnir.CryptoTransfer.ts:810, :1121); `Maker balance too low: ...`, `Taker balance too low: ...` (Varnir.Swap.ts:251, :255, :475, :479); `Party A/B balance too low: ...` (Varnir.Swap.ts:724, :728)No — fund the account, then a new transaction
NOT_OWNERThe signing key lacks the stake/authority for this action"Stake Permission Error - Read Only 9000" (Varnir.CryptoTransfer.ts:357, Varnir.Keys.ts:28); "Incorrect Signing Key" (Varnir.Keys.ts:41); "AFX Missing" (:362); "Maker/Taker key is read-only and cannot swap/fill/cancel" (Varnir.Swap.ts:233, :236, :372, :922); "No Owner Found" (:2332); "No Contract Authority" (:2360)No — needs a different/stronger key
ALREADY_SETTLEDThe thing this tx targets already reached a terminal state"Order already filled or cancelled" (Varnir.Swap.ts:405, :411, :691, :692); "Order already fully filled or cancelled" (:704); "Approved Already" (Varnir.CryptoTransfer.ts:393); "Already Signed" (:1317)No
EXPIREDPast the object's own validity window — an order, a treasury proposal, or (Activeledger 4.8.0+) an authority key that has passed its own expire"Order has expired" (Varnir.Swap.ts:363); "Order A/B has expired" (:652, :653); "Proposal Expired" (Varnir.CryptoTransfer.ts:749, confirming a proposal past its expiresAt); the engine's own "Input Signature Expired" / "Output Signature Expired" — code 1235, raised by Activeledger 4.8.0's permissions checker for a lapsed authority, matched on the shared "signature expired" half (errors.ts:229-231); and the scanner's two REST refusals for a lapsed authority on the header-auth path — "This key has expired…" (scanner-web/src/lib/ledger-scopes.ts:45) and "This key's expiry could not be read…" (:58), matched on "has expired" and "expiry could not be read" (errors.ts:259-264). Both wordings are load-bearing and carry a guard comment saying so; expiry-error-mapping.test.ts pins themYes, but only by building a brand-new signed object (new expire/salt/$expire) — never by resending the same bytes. A lapsed key is not retryable at all: it needs re-issuing
TREASURY_GATEDBlocked specifically by the propose/confirm separation-of-duties rule, while the underlying proposal is still alive"Approval Mismatched" (Varnir.CryptoTransfer.ts:389); "Cannot verify your own proposal" (:411); "A verifier key cannot raise transfers" (:457)No — needs the correct role/counterparty; note a successful treasury-gated send() is not an error at all (see §6.1)
CONTRACT_REJECTEDAny other LedgerRejection — the fallback bucketEverything else a contract's reject(...)/throw produces that doesn't match a more specific code above, e.g. "Orders do not cross: the incoming taker's bid is below the resting maker's ask" (Varnir.Swap.ts:647), "Wallet Signing Locked" (Varnir.CryptoTransfer.ts:1450), "Check Key Health" (:1349)No, not blindly — inspect the message
VALIDATIONMalformed request, caught before or by the contract's own format checksClient-side fail-fast: fillAmount/minReceive format & range (varnir-client.ts:1453-1484), maxSlippageBps range (:1769-1770), self-trade checks (:1278-1279, :1445-1446, :1538-1539), unknown token (:1119-1123 etc.); contract-side: `${label} is missing $stream/network/amount`, `${label} amount must be a decimal integer in smallest units` (Varnir.Swap.ts:953-1002)Yes, after the caller fixes the request
NOT_FOUNDA referenced id doesn't exist / isn't the type expected`${proposalId} is not a treasury proposal on this network` (varnir-client.ts:1945, client-side, getTransferProposal already returned null); `Unknown token ${token} on ${network} network` (Varnir.CryptoTransfer.ts:2272)No — depends what the caller does next
NODE_UNREACHABLENo node/gateway in the list answered, or all timed out. Transport, not rejection — see the note below the table: this is the one code classifyRejectionMessage never returns`None of the ${n} chain nodes answered. Last transport error: ...` (send-to-any-node.ts:129-134); also constructed directly at varnir-client.ts:1651-1656 (strictReservedGuard could not read open orders) and :1875Yes — the only code isBlindlyRetryable returns true for (errors.ts:102-104), and so the only one safe to retry by resending the same bytes; see §7
POLICY_PER_TX_EXCEEDEDA spend policy on the signing key refused the movement: the amount is over that asset rule's perTxMax"POLICY_PER_TX_EXCEEDED" — the code itself IS the rejection string (Varnir.CryptoTransfer.ts:568; Varnir.Swap.ts:1249)No — send less, or use a key with a wider rule
POLICY_ASSET_DENIEDThe key's policy is asset-allowlisted (assetsAllowlisted) and this asset has no rule"POLICY_ASSET_DENIED" (Varnir.CryptoTransfer.ts:537; Varnir.Swap.ts:1239)No — needs a rule for that asset, added by the owner key
POLICY_RECIPIENT_DENIEDThe recipient is not on the key's allowlist"POLICY_RECIPIENT_DENIED" (Varnir.CryptoTransfer.ts:558). Transfer/withdrawal only — deliberately not enforced for swaps, where the counterparty is market-chosen rather than a payee the owner could allowlist (Varnir.Swap.ts:1242-1245)No — needs the recipient allowlisted
POLICY_PERIOD_CAP_EXCEEDEDThe rule's rolling-window cap (periodCap/windowMs) is exhausted for this window"POLICY_PERIOD_CAP_EXCEEDED" (Varnir.CryptoTransfer.ts:603; Varnir.Swap.ts:1274)Not now, but yes later — this is the one policy refusal that clears itself when the window rolls (tumbling, on the consensus clock)
POLICY_COSIGN_REQUIREDA swap leg exceeded the key's coSignOver threshold"POLICY_COSIGN_REQUIRED" (Varnir.Swap.ts:1290) — swaps only. A send() over the threshold does not throw this: it resolves with a pending proposal instead, so it is a success to detect on VarnirTransfer.proposal (see §6.1). A catch on this code will never fire for a co-sign-held transferNo — a swap is atomic, so an over-threshold leg is safe-rejected outright rather than escrowed
SCOPE_DENIEDThe key lacks the off-ledger REST scope for this route (server-side header auth)`This key does not have the '${perm}' permission.` (scanner-web/src/lib/ledger-scopes.ts:215). Matched on the /does not have the '.*' permission/ substring rather than the whole sentence (errors.ts:207), which is why the retired D1 store's differently-prefixed wording mapped here tooNo — needs a key carrying that scope
REPEAT_PAYMENTThe repeat-payment guard refused: an identical payment (same sender, asset, raw amount, recipient and ref) already committed within the last 10 minutes`Repeat payment: identical to ${umid} at ${iso}. Send again with repeat:true if intended.` (Varnir.CryptoTransfer.ts:2525). .priorUmid/.priorAt are parsed out by parseRepeatRefusal and carried on the errorNo, not blindly — resend with repeat: true (owner key only) or a different ref
NOT_OWNER (same code, new message)The repeat: true override was set by a key other than the stake-100 owner"Repeat override needs the owner key" (Varnir.CryptoTransfer.ts:2547)No — only the owner key may override
OUTCOME_UNKNOWNThe transaction committed, but a stream it created could not be read back in time (read-after-write lag, see §6.1), so whether it moved money or is held for co-sign is not known yet. .committedUmid (committed_umid / CommittedUmid per language) names it. Not "nothing happened", and not NOT_FOUNDConstructed client-side, not message-matched: `Transaction ${umid} COMMITTED, but the stream it created (${id}) could not be read back, ...` (varnir-client.ts, readCreatedStreamDoc)Never resend. Confirm first (getTransferProposal on the created stream, or the transaction history)
VALIDATION (same code, new messages)A malformed ref or repeat value"Invalid repeat flag" (Varnir.CryptoTransfer.ts:2537); "Invalid payment ref" (:2544)Yes, after the caller fixes the request

Codes in the TypeScript reference that not every port classifies yet:

codeMeaningConcrete message(s) it maps fromRetryable?
IDENTITY_RECOVEREDAn outgoing transaction named an identity that social recovery has moved to a successor. VarnirError.newIdentity carries the new id. An incoming credit to it is not refused"Identity recovered - new identity is <64 hex>; your keys are still valid"No — redirect to the new identity
WITHDRAWAL_HELDThe payment is held by the new-recipient protection; nothing has gone out. .holdUntil names when it releases by itself`WITHDRAWAL_HELD: withdrawal held until ${iso}`, `WITHDRAWAL_HELD: transfer held until ${iso}`Not before .holdUntil — or speed it up with the emailed code
HOLD_CODE_INVALIDThe speed-up code is not a code at all, or none was issued. A well-formed wrong code is not an error: it counts an attempt and returns released: false"HOLD_CODE_INVALID: invalid speed-up code", "HOLD_CODE_INVALID: no speed-up code has been issued for this hold"After fixing the code
HOLD_SPEEDUP_LOCKEDFive wrong codes: speed-up is locked; the hold runs its term (cancel still works)"HOLD_SPEEDUP_LOCKED: speed-up locked after 5 wrong codes"No
HOLD_OWNER_KEY_REQUIREDSpeeding up a hold, or turning the protection off, needs the owner key"HOLD_OWNER_KEY_REQUIRED: speed-up needs the owner key", "HOLD_OWNER_KEY_REQUIRED: turning the protection off needs the owner key"No — needs the owner key
HOLD_NOT_ACTIVEThe hold was already released, sped up or cancelled"HOLD_NOT_ACTIVE: hold already cancelled", "HOLD_NOT_ACTIVE: hold already released" and variantsNo
CHECK_NOT_ENABLED, INSUFFICIENT_CREDITS, QUOTE_EXPIRED, HOLD_REJECTED, PRICE_TOO_HIGH, UPLOAD_REJECTED, RATE_LIMITED, CHARGE_PENDING, CHECK_UNAVAILABLEWallet check (checkWallet and companions). Mapped from the check API's {error} codes and the Plans holdService refusals, not from classifyRejectionMessageSee Check a walletPer the error's own retry: retrying with the same ref never charges twice

NODE_UNREACHABLE is the one code that is not message-matched. Every other row above is reached through classifyRejectionMessage, which maps a contract's rejection string to a code; a transport failure produces no such string, so NODE_UNREACHABLE is constructed directly at the point the transport gives up and classifyRejectionMessage never returns it. Note the gap this leaves on the read path: gateway-get.ts (:47-51, :100-104) throws a plain Error with a near-identical "None of the N gateways answered" message and no code at all, so a port should not assume every transport failure in this SDK arrives as a VarnirError.

6.1 Not an error: the treasury "silent success"​

send()'s return value can indicate a transaction that succeeded at the ledger layer but moved no money, when the identity has a treasury and the amount exceeded its threshold — the contract resolves the vote as true having written a proposal stream instead of debiting anyone (Varnir.CryptoTransfer.ts:366-375, :460-463; client-side detection via findProposalAmong, varnir-client.ts:1173-1179). This is not a LedgerRejection and should not be mapped into the error taxonomy above at all — a port must surface VarnirTransfer.proposal (or equivalent) as a distinct, checked outcome, and every caller of a money-sending method must be told, in the type system if possible, to check it before reporting success.

Read the created stream until it is visible. Under Activeledger v5's P2P commit, the reply can arrive before every node has written the transaction, and the gateway the SDK reads may be one that has not (docs/deploy/2026-09-28-activeledger-v5-testnet.md §4.2 #1). Every id in $streams.new exists once the commit lands, so a not-found read of a created stream is lag, not "not a proposal". Every port reads each created stream until it is visible, with backoff (250 ms, 500 ms, 1 s, 2 s, 4 s: six reads over about 7.75 s). Each stream is retried, not only the first, because reads go to whichever gateway answers: a failover between two reads can land on a node that has not written the commit yet. If a stream never appears, the port raises OUTCOME_UNKNOWN carrying committedUmid, and callers must treat it as "may have gone through": wallet-ui shows the uncertain lock, and the agent server answers 409 telling the assistant to confirm before retrying. The behaviour is pinned for every port by the generated readback.json vectors. A caller's cancellation that lands during the read-back (a Go ctx deadline, a .NET CancellationToken, a JVM thread interrupt) still ends in OUTCOME_UNKNOWN with the cancellation as its cause, never a bare timeout: the transaction has already committed, and a timeout invites a retry that would pay twice. Python, PHP, Rust, Dart, Swift and TypeScript have no cancellation hook on send.


7. Idempotency and retry​

The umid (unique message id, $umid in a ledger response) is assigned by ActiveLedger core from the transaction's own signed content — including $expire (varnir-client.ts:77-92's comment on stampExpiry is the authoritative source for this whole section). Two consequences follow directly, and they are the entire retry-safety model:

  1. Resubmitting byte-identical signed bytes is safe and gets deduped. $expire is stamped before signing, so it is inside the signed message and therefore an input to the umid. sendToAnyNode's internal node-failover (send-to-any-node.ts:99-124) POSTs the same already-signed tx object to each node in turn until one answers — every attempt carries the same umid, so the ledger treats a second (or third) delivery of the same tx as the same transaction, not a double-send.
  2. A caller-level retry that rebuilds the transaction is NOT safe if it restamps $expire (or regenerates order.salt) — that produces a different signed message and therefore a different umid, which the ledger will happily execute as a brand-new, unrelated transaction. If the original attempt actually reached a node and committed, but the caller never saw the response (network partition, process crash before reading the HTTP response), a naive "just call send() again" doubles the transfer.

With a post-quantum key (the default — §2) point 2 is stricter still: the signature itself is randomised, so re-signing an otherwise byte-identical $tx produces different $sigs bytes even when $expire is unchanged. See §2.5 for what that does and does not change, and §10 for the one part of it that is still unconfirmed.

This is why every money-moving method in §3 stamps $expire inline, immediately before signing, and why sendToAnyNode is the only sanctioned place to retry across nodes — it retries the identical bytes. A port must preserve this shape: build the object, stamp expiry, sign, then hand the exact same signed bytes to every retry attempt within one logical call. It must never re-run the "build → stamp → sign" pipeline as its retry strategy for the same logical intent.

The repeat-payment guard is the ledger's own backstop for exactly the "naive re-send" failure mode point 2 above describes. Independent of $expire/umid, Varnir.CryptoTransfer now refuses a re-signed retry of a payment (same sender, asset, raw amount, recipient and ref) committed within the last 10 minutes — REPEAT_PAYMENT, naming the prior umid (.priorUmid/.priorAt). A caller that re-signs and resends after an unknown outcome (crash, timeout, no response read) and sees REPEAT_PAYMENT should treat .priorUmid as the likely landing of its payment rather than as a failure — but verify it before assuming it was yours: the guard matches on the fingerprint, not on who signed, so .priorUmid may just as well be an identical payment made in the same window by another key or device of the same identity.

A retry must pin the same settlement as the original attempt. The fingerprint's recipient is the contract's own resolution of to: the credited identity's stream on the ledger route (transfer), but the L1 address on the L1 route (withdrawal) (repeatPaymentDetails, Varnir.CryptoTransfer.ts:2460). send()'s default 'auto' repeats its /wallets/by-address lookup on every call, so an attempt whose lookup failed (and fell back to L1) and a retry whose lookup succeeded (and went to the ledger), or the other way round, carry different fingerprints — the guard cannot see the retry as a repeat and the payment goes out twice. A caller that may retry after an unknown outcome should decide the route once, pass that explicit settlement: 'ledger' | 'l1' on the first attempt, and pass the same value on every retry; if the first attempt ran on 'auto', the guard is not a safe basis for a retry — check the ledger instead. Passing repeat: true deliberately bypasses the guard for a genuinely intended duplicate — restricted to the stake-100 owner key — and a different ref sidesteps it entirely, since ref is part of the fingerprint.

7.1 What LedgerRejection vs. transport failure means for retrying​

send-to-any-node.ts:64-97's doc comment is the ground truth here, verified against a live 4-node network:

  • A rejection ($summary.errors present and $summary.commit < 1, i.e. nothing committed anywhere) is thrown as LedgerRejection and is not retried against the next node — every node in a Varnir network reaches the same consensus, so a contract-level "no" from one is the answer from all of them. Retrying it is pointless at best.
  • A transport failure (timeout, connection refused) moves on to the next node in the list, because that node specifically didn't answer, not because the network said no.
  • A rare third case — "committed anyway": one node reports an error (typically a stale-replica message like "Input Stream Position Incorrect") while $summary.commit >= 1, meaning the transaction did reach consensus and money did move, despite an error string being present. sendToAnyNode correctly treats this as success (returns the body rather than throwing) precisely because it checks commit, not "were there any errors at all." A port that naively throws on errors.length > 0 will report a successful transfer as a failure and invite exactly the unsafe retry described above.

7.2 Per-method retry-safety summary​

MethodSafe to retry (same call, transport failure)Safe to call again with fresh inputs after an unknown outcome
send, transferOnLedger, confirmTransfer, fillOrder, matchOrders, cancelOrder, key changesYes — sendToAnyNode already does this internallyNo — re-derive the outcome via getTransfer(umid)/waitForTransfer (varnir-client.ts:2353-2394) once you have a umid; if you never got one, treat as unknown and do not resend blindly
submitSwapYes (same reason)No, same caveat — but note buildSwap/signSwap themselves are pure local computation with no network I/O and are trivially re-runnable
createOrderN/A — no network callYes — it's a pure local signing operation; each call produces a different order (fresh salt), which is fine since nothing has been submitted yet
postOrderAlready fans out to every gateway and succeeds if any one accepts (varnir-client.ts:1622-1632) — designed to be over-deliveredYes — posting the same signed order twice is idempotent from the contract's point of view (same orderHash)
marketOrderInternally retries across candidates on a detected race (varnir-client.ts:1838-1852), not across nodes for the same fillDepends on whether any fillOrder inside it committed — same caveat as fillOrder above

8. Transport​

Two transport families exist in this package; only one is the canonical target for a port:

  • Canonical (implement this in a port): the fetch-based, direct-HTTP path — sendToAnyNode (send-to-any-node.ts) for signed writes, and gatewayGet/gatewayPost (gateway-get.ts) for reads against a minigate gateway. Both use only global fetch/AbortController (send-to-any-node.ts:9-11), POST a plain JSON body to {node}/, and fail over across a node/gateway list on transport error only (§7.1). This is what VarnirClient (the class this whole document describes) uses for every operation in §3, and it is trivially portable to any language with an HTTP client — no SDK dependency required.
  • Not for ports: NodeTransport/WebTransport (node-transport.ts, web-transport.ts) — thin wrappers around @activeledger/sdk-node and @activeledger/sdk-web respectively, kept only because some existing TypeScript consumers (server-side Node processes vs. browser/React Native) need the official SDK's Connection/KeyHandler/TransactionHandler surface for reasons specific to those environments (see each file's own doc comment — e.g. web-transport.ts:1-6 on why sdk-web avoids Node polyfills in React Native). These exist for platform compatibility inside the TS ecosystem specifically, carry a dependency on Activeledger's own SDK packages, and encode nothing about the wire protocol that isn't already fully specified by §1-§7. A Go/Python/Rust port has no reason to wrap an equivalent of these — it should talk the raw HTTP protocol directly, exactly as sendToAnyNode does.

A node response's shape a port must parse (send-to-any-node.ts:41-45):

interface LedgerResponse {
$umid?: string;
$summary?: { vote?: number; commit?: number; errors?: string[] };
$streams?: { new?: { id: string }[]; updated?: { id: string }[] };
}

$summary.commit > 0 is the success test (§7.1) — not $umid presence alone and not errors.length === 0.

8.1 Payout speed on send()​

send() takes an optional speed: economy (the default), standard or instant. It is not part of the signed $tx, so it never changes the umid or the repeat-payment fingerprint (§7).

  • Validate first. An unknown value is VALIDATION before anything is sent.
  • Result. An L1 withdrawal that moved money (no proposal) reports speed = the given value, or economy when omitted. Ledger settlements and proposals carry no speed.
  • Recording. Only when the caller passed a speed explicitly, after the withdrawal succeeds: POST {apiBaseUrl}/api/withdrawals/speed with body exactly {"umid":"…","speed":"…"} (keys in that order; that string is what is signed and sent), content-type: application/json, and the API signing headers for route tag POST /api/withdrawals/speed (${routeTag}\n${timestampMs}\n${body}), signed with the identity's own ledger key, not an attached API key.
  • Never fails the send. One retry after about 2 s on a 503 or a network error, nothing else retried, a 10 s timeout per attempt, and no exception: the result's speedRecorded is true on a 2xx, false otherwise, and absent when no speed was passed.
  • Vector: send.toExternalAddress.speed in requests.json.

Speeds are only recorded today: every withdrawal still goes out straight away and is charged at the Standard rate.

8.2 Verifying webhooks​

A standalone function (no client needed). Deliveries carry X-Varnir-Timestamp (unix seconds) and X-Varnir-Signature: v1=<hex>, where the hex is HMAC-SHA256 keyed with the secret's UTF-8 bytes over utf8("${timestamp}.") followed by the raw body bytes. A port returns false (never throws) unless all of these hold:

  • the timestamp is ASCII digits only, within 300 s of now either way (inclusive);
  • the signature starts with v1=;
  • the hex matches, compared in constant time (uppercase hex accepted);
  • each header appears exactly once (read case-insensitively).

Verify the raw body exactly as received, before parsing JSON. Reference: packages/chain-client/src/webhooks.ts.


9. Versioning​

There is currently no explicit version field anywhere in the signed payload or the transport, and no mechanism for a client to advertise or negotiate which signing/serialization contract it speaks. This document itself has to fill that gap for future changes:

  • This document is Spec v1 (core-contract-v1), describing exactly the behaviour of @varnir/chain-client as read at the commits cited throughout (the git log head at time of writing includes b146b15, 05a46ea, 87db7cd). Every citation is subject to drift as the client evolves — a change to any of the cited line ranges is, definitionally, a spec change and should update this document in the same PR.
  • Proposed going forward: add an explicit spec-version marker any new signing scheme change would need. Two independent, deliberately low-friction proposals for a human to choose between:
    1. A domain field's implicit version (already present on resting orders: {namespace, contract} pins an order to one deployment). Extending domain with a specVersion (or similar) integer, checked by the contract, would let two signing schemes coexist during a migration — an old order fails "domain mismatch" against a new contract instead of failing some less legible way.
    2. A per-network signingScheme advertised in VarnirNetworkConfig (networks.ts), read by the SDK before building any transaction, so a port can assert if (config.signingScheme !== 'v1') throw rather than silently producing v1-shaped bytes against a v2-only contract deployment.
  • Either way, a breaking change to §1's field-order table or to either of §2's signing primitives (including adding a fourth key type, or changing the post-quantum message construction in §2.3) is a major version bump of this document, and must not ship silently — every existing signer (every port, and the TS client itself) needs coordinated redeployment, because old signatures for the changed transaction type stop verifying.
  • A non-breaking addition (a new optional field appended at the end of an existing object, following §1.4's rule that $expire-style late additions go last) is a minor bump — existing signers whose payloads omit the new field must keep verifying exactly as before, which is only guaranteed if the contract treats the field as optional server-side too.

10. Open issues for a clean port​

Known gaps that make a clean, from-scratch spec harder than it should be:

  1. §1.3: the signature scheme depends on insertion-order JSON, not a canonical (sorted) form — meaning "reproduce the field order documented in §1.4" is a harder, more error-prone requirement for a port than "sort the keys" would be, and any future field added to a signed object must be appended, never inserted, to avoid silently breaking every existing signer. A JCS migration (§9) would remove this whole class of risk but is a coordinated breaking change, not a drop-in fix.
  2. §5: toLedgerCode's 'tBNB' vs. the raw registry's 'TBNB' is a genuine, acknowledged, out-of-scope casing inconsistency (network-codes/src/index.ts:99-104) that a port must special-case rather than assume one canonical casing throughout.
  3. §6 — RESOLVED. The engine-level expiry wording is confirmed and is in the EXPIRED row: Activeledger 4.8.0 rejects a lapsed authority with code 1235, reason "Input Signature Expired" / "Output Signature Expired" (one template, the side chosen by which is being checked), deliberately distinct from the 1220 "... Signature Incorrect" it raises for a key that simply is not yours. classifyRejectionMessage matches the shared "signature expired" half, before the NOT_OWNER block, and expiry-error-mapping.test.ts pins that ordering so a future reword cannot silently re-bucket a lapsed key as a wrong one. The distinction is worth keeping: bucketing 1235 with 1220 would tell a caller "that is not your key" about a key that is theirs and worked yesterday, sending them to re-provision rather than to re-date.
  4. §6: several rejection strings are ambiguous between two codes by design (e.g. "Approved Already" could be ALREADY_SETTLED or TREASURY_GATED) — this document picked one bucket per message and stated the disambiguating rule (§6, ALREADY_SETTLED row's preamble), but a stricter spec might want a secondary subcode for these instead of forcing a single bucket.
  5. Line-number citations drift. Every varnir-client.ts:NNN citation is "as of this document's own commit" and should be re-verified, not assumed, before a port relies on it — varnir-client.ts has already grown by roughly 960 net lines since §1's citations were written, so the §1, §3, §4, §6 and §7 citations point several hundred lines short of the code they describe (§2's are current). Cite by symbol name first and line number second when reading this document, and treat a mismatch as a citation bug rather than a behaviour change.
  6. §2.5: whether the ledger's umid is derived from $tx alone or from the whole envelope including $sigs could not be confirmed — the computation lives in ActiveLedger core, which is not a dependency of this repository. The installed @activeledger/* packages are activecontracts, activecrypto, activedefinitions, activelogger, activequery, activetoolkits, activeutilities, httpd and the three sdk-* packages; none of them computes a umid. It did not matter while every signature was deterministic, because a re-signed identical $tx produced identical $sigs too; with a randomised post-quantum signature it decides whether "rebuild the same $tx and re-sign it" is deduped or executed twice. Whoever next has a live node should submit one $tx signed twice by a Falcon key and record whether the two umids match, then state the answer in §2.5 and §7. Until then, §7's existing rule — retry only the identical already-signed bytes — is the safe behaviour under either answer, and is what a port must implement.