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.
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
- Canonical JSON serialization — the #1 reimplementation blocker
- Signing pipeline — two primitives; the default is post-quantum
- 2.1 One entry point, two primitives
- 2.2 The classical primitive (secp256k1)
- 2.3 The post-quantum primitive (Falcon-512 and ML-DSA-65)
- 2.4 Public keys on the wire, and the identity
- 2.5 Determinism, and what a port can pin
- 2.6 Recovery phrases
- 2.7 Worked example: a post-quantum identity
- 2.8 How
$sigsis keyed
- Transaction shapes
- Amount encoding
- Network codes
- Error taxonomy
- Idempotency and retry
- Transport
- Versioning
- 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 withtypeof 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:
- Key order = insertion (declaration) order, not alphabetical, not
schema-declared. V8's
JSON.stringifywalks 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.dumpsin Python 3.7+ over adictalready does;encoding/jsonin 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.[]KVpairs 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." - No whitespace. No space after
:or,, no newlines, no indentation — this isJSON.stringify(value)'s default compact form. - Numbers are emitted as JSON numbers, using JS's number-to-string
algorithm, when the field is a
numberin the source object. Only two kinds of numeric fields appear in anything signed by this SDK:$expire(ms-since-epoch, e.g.1893456000000) andorder.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, not1893456000000.0, not1.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.0for whole numbers. - 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.
- String escaping: only
",\, and control characters< 0x20are 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. $sigs(and, foronboard,$selfsign) are never part of the signed payload. Only the inner object actually passed tosignPayloadis signed — for ledger transactions that istx.$tx, not the full envelope{$sigs, $tx}(sendatvarnir-client.ts:1605,transferOnLedgerat:1783,signSwapat:1885, and every other site listed in §2.1: the signature is always computed ontx.$tx, then stored intotx.$sigsafterward — the container can't be self-referential). For resting orders, the signed value is the bareorderobject 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 theSignedOrder.sigvalue):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 order | Insertion/declaration order | Lexicographic (UTF-16 code unit) sort, always |
| Numbers | JS ToString(Number) | ECMAScript-compatible ToString, same algorithm — no divergence for the integers actually used here |
| Whitespace | None (JSON.stringify default) | None |
| String escaping | V8 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 object | Root key order | Source |
|---|---|---|
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}}, $expire | varnir-client.ts:1226-1248 |
buildSwap() tx $tx | $contract, $namespace, $entry, $i{maker{$stream,network,token,amount}, taker{$stream,network,token,amount}}, $expire | varnir-client.ts:1281-1291 |
fillOrder() tx $tx | $contract, $namespace, $entry, $i{taker{$stream,network,token,amount[,minReceive]}}, $o{maker{$stream,order,sig}}, $expire | varnir-client.ts:1495-1509 |
matchOrders() tx $tx | $contract, $namespace, $entry, $i{submitter{$stream}}, $o{a{$stream,order,sig}, b{$stream,order,sig}}, $expire | varnir-client.ts:1560-1578 |
cancelOrder() tx $tx | $contract, $namespace, $entry, $i{maker{$stream,orderHash}}, $expire | varnir-client.ts:1601-1612 |
confirmTransfer() tx $tx | $contract, $namespace, $entry, $i{owner{$stream,network,token,amount,to}}, $o{approved{$stream}}, $r{}, $expire | varnir-client.ts:1972-2005 |
onboard() tx $tx (self-signed, no $entry) | $contract, $namespace, $i{otk{publicKey,type}}, $expire | varnir-client.ts:803-816 |
Key-change tx $tx (addKey/threshold, no $entry) | $contract, $namespace, $i{owner{$stream, ...roleInput}}, $expire | varnir-client.ts:2051-2067 |
Two structural notes that a port must not miss:
$expireis always appended last, becausestampExpiry(varnir-client.ts:106-108) mutates the already-built$txobject 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.onboardand the key-change helper omit$entryentirely — that field is not present at all in those two shapes, not merely empty. Do not send$entry: nullor$entry: ''; omit the key.- Optional fields (
toonsend,minReceiveonfillOrder'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 omittedminReceiveleaves that object... identical to before this option existed"). A port must literally omit the key, not include it with anull/empty value, or its serialization will not match a peer that omitted it, and the two will disagree on$txbytes 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.
type | Primitive | Default for a new identity? | Signed through |
|---|---|---|---|
falcon-512 | Falcon-512 | Yes | the Activeledger SDK's own WebCryptoProvider → @noble/post-quantum's falcon512 (§2.3) |
ml-dsa-65 | ML-DSA-65 (FIPS 204) | No — the other supported post-quantum option, same code path | the same provider → @noble/post-quantum's ml_dsa65 (§2.3) |
secp256k1 | ECDSA over secp256k1, DER-encoded | No — the classical fallback, and what every pre-4.7 identity holds | manual-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):
- Ledger transaction signing (what this document is mostly about): the
message is
tx.$tx(or the bareorder, for a resting order). The key is the identity's own onboarding key, or a delegated key with sufficient stake (see §6/Treasury). - 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 anaddKey({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 theX-Varnir-Api-Keyheader in its tagged form (manual-sign.ts:70-80, asserted bytest/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:
keyTypeOf(privateKeyHex)(pq-keys.ts:88-93): if the string starts withfalcon-512:orml-dsa-65:, that is the type; anything else issecp256k1. The type is carried in the key string, so there is no separate type argument to get wrong, and every pre-existing0x-hex key keeps resolving classically with no migration.- Build the message:
typeof payload === 'string' ? payload : JSON.stringify(payload)(pq-keys.ts:173) — §1's rules, identically for every type. - 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:28strips a leading0x); 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 fromvarnir-client.ts:950) always passestruetosecp256k1.getPublicKey. - ECDSA signing here is deterministic (
@noble/curves's defaultsecp256k1.signuses 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 randomk, 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 beyondverify. 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:
- 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 tofalcon512.signproduces 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. - Fresh entropy on every signature (
crypto.js:76-77), which is what makes these signatures non-reproducible — §2.5. - 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 bypostQuantumSecret(pq-keys.ts:96-105), which throws if the string does not carry both halves. - 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-222replicates 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-512 | ML-DSA-65 | |
|---|---|---|
| keygen seed | 48 bytes | 32 bytes |
| public key | 897 bytes | 1952 bytes |
| secret key | 1281 bytes | 4032 bytes |
| signature | variable — 648–663 bytes observed over 2000 signatures; the library declares no fixed signature length for Falcon at all | 3309 bytes, fixed (and declared as such) |
| public key, base64 | 1196 chars | 2604 chars |
| secret key, base64 | 1708 chars | 5376 chars |
tagged public key string (<type>:<b64 pk>) | 1207 chars | 2614 chars |
tagged private key string (<type>:<b64 sk>:<b64 pk>) | 2916 chars | 7991 chars |
signature, base64 (what $sigs carries) | 864–884 chars, varying per signature | 4412 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):
| Form | Shape | Where it is used |
|---|---|---|
| Tagged | falcon-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 field | The 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 |
| secp256k1 | 0x-prefixed compressed hex | Both 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
secp256k1only. 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 assertfalse(a control that catches a verifier stubbed to returntrue). - 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), withRECOVERY_PHRASE_LENGTH = 12exported 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 asrecoverySeedFromPhraseso the post-quantum derivation reuses this exact step rather than duplicating it.
Then the two tails diverge:
secp256k1 | falcon-512 / ml-dsa-65 | |
|---|---|---|
| Function | derivePrivateKeyFromPhrase (signing/src/index.ts) | deriveKeyPairFromPhrase (pq-keys.ts) |
| Derivation | HMAC-SHA512("Bitcoin seed", seed)[0..32] — the BIP-32 master scalar | HKDF-SHA512, info activeledger-seed-v1:<type>, then the scheme's keygen |
| Output | 0x-hex private + compressed public | the 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.
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.
infois the domain separation, and it contains the type. The same phrase therefore yields a different key — and a different identity — forfalcon-512than forml-dsa-65.- The two paths are not interchangeable.
deriveKeyPairFromPhraserefuses'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-65 | secp256k1 | |
|---|---|---|---|
| identity | 71d634437de4e5baccbdafa242d275e19886322493b2cb082d55966c95c14f5d | 4fbb4b128e8a12f1572196c4a94148c87d7c241b3df7328fec8d3dd46c5921fc | e2fdb1930d5d880ba71c26049004762ea4e49856b2e43e50ebb9b4b49640098f |
| tagged public key | falcon-512:CYvkdYQFRJlbWvFTp…yTHBeXJpeG (1207 chars) | ml-dsa-65:mTRJc+nwjgcfTLrOzm…s8DeMGEdE= (2614 chars) | 0x03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494 |
| SHA-256 of the tagged public key string | 3339d9c275c4b2dfa31bd1081948ebd2f4f2b2b89d863044d9299d6cbfddc99c | c3607be604d510d1e2d38e71b6f8e76b2db93e9a94e8d77fe56b2435dee63176 | — |
| SHA-256 of the untagged (on-wire) public key string | efc19d3d6f16a0554ab848063ca6b4ef576e3e97461d57d96ee3ab9d8753e680 | b3bd18891a3b86d26754481282710bec2c0cedf11ef7d7d4ae6eadc4318e6980 | — |
| private key | 2916 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
verifyPayloadaccepted under that key's tagged public key. - The same signatures, checked against the order with
expirechanged 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).
| Operation | Contract entry | $i (needs a matching $sigs entry) | $o (no signature needed here) | Client method |
|---|---|---|---|---|
| Onboard | Varnir.Onboard (no $entry) | otk{publicKey,type} | — | VarnirClient.onboard — varnir-client.ts:791-832 |
| Withdraw to L1 | Varnir.CryptoTransfer sign | owner{$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 transfer | Varnir.CryptoTransfer transfer | owner{$stream,network,token,amount} | to{$stream} | transferOnLedger — varnir-client.ts:1205-1256 |
| Confirm treasury proposal | Varnir.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 swap | Varnir.Swap swap | maker{$stream,network,token,amount}, taker{$stream,network,token,amount} (both must sign) | — | buildSwap/signSwap/submitSwap — varnir-client.ts:1269-1354 |
| Fill a resting order | Varnir.Swap fill | taker{$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 orders | Varnir.Swap match | submitter{$stream} (authorises nothing but the submission) | a{$stream,order,sig}, b{$stream,order,sig} | matchOrders — varnir-client.ts:1531-1586 |
| Cancel a resting order | Varnir.Swap cancel | maker{$stream,orderHash} | — | cancelOrder — varnir-client.ts:1594-1620 |
| Add/change a key | Varnir.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'samountparameter (SendOptions.amount,LedgerTransferOptions.amount,SwapLeg.amount,OrderLeg.amount— all typedstring | 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) andfillOrder'sfillAmount/minReceiveoptions (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 exceed2^63). A port must use an arbitrary-precision integer type (Go'smath/big.Int, Python's nativeint, 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:
- Trim the input; reject (throw) anything not matching
^\d*(\.\d*)?$, and reject the empty string or a bare.. - Split on
.intowholeandfraction(fraction defaults to''). - Reject (throw) if
fraction.length > decimals— this never rounds; a caller supplying more precision than the asset supports gets an error, not silent truncation. raw = whole (or '0' if empty) + fraction padded on the right with zeros todecimalslength, then strip any leading zeros (but never reduce it below a single"0").- 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:
| Form | Example | Where used | Direction |
|---|---|---|---|
| A — ledger/credit code | SEP, TRX-NILE, tBNB | Stored 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 code | sep, niles, tbnb | VarnirChain values (SendOptions.wallet.chain, LedgerTransferOptions.chain, SwapLeg.chain, etc.) — what a caller passes | toCanonical |
| C — provider chain id | ethereum-sepolia, tron-nile | Off-wire, provider-facing only | n/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 withtoLedgerCode's'tBNB'. A port doing a registry lookup must upper-case; a port building a signed transaction must usetoLedgerCode'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 extrabitcoinobject 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)andfromRawAmount(x, 8)are the same algorithm as §4, andcore.jsonpins'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-testnet4andbtc-regtest(any other code is refused). It is a port ofdecodeAddressinpackages/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 withVALIDATION. Bech32 is normalised to lower case; base58 keeps its one form. The cases arebitcoin.json(addresses), generated from btc-core bygenerate-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 ledgerumid, not a Bitcoin txid. A Bitcoin withdrawal is the §3signshape like any other L1 withdrawal (networkis the ledger code,amountis 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:
- Validate the format for the requested network. Refuse with
VALIDATIONand stop. Nothing has been looked up, signed or sent. - Look up whether Varnir already knows the address (
/wallets/by-address) and, if it does, settle on the ledger instead of L1 (§8,settlement). - 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:
| family | to is valid when |
|---|---|
evm | 0x 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) |
tron | a 34-character base58check address whose payload is 21 bytes starting 0x41 (a T… address). The 42-hex 41… form is not a send destination |
bitcoin | accepted 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.
code | Meaning | Concrete message(s) it maps from | Retryable? |
|---|---|---|---|
INSUFFICIENT_BALANCE | The 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_OWNER | The 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_SETTLED | The 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 |
EXPIRED | Past 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 them | Yes, 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_GATED | Blocked 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_REJECTED | Any other LedgerRejection — the fallback bucket | Everything 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 |
VALIDATION | Malformed request, caught before or by the contract's own format checks | Client-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_FOUND | A 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_UNREACHABLE | No 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 :1875 | Yes — 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_EXCEEDED | A 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_DENIED | The 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_DENIED | The 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_EXCEEDED | The 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_REQUIRED | A 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 transfer | No — a swap is atomic, so an over-threshold leg is safe-rejected outright rather than escrowed |
SCOPE_DENIED | The 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 too | No — needs a key carrying that scope |
REPEAT_PAYMENT | The 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 error | No, 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_UNKNOWN | The 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_FOUND | Constructed 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:
code | Meaning | Concrete message(s) it maps from | Retryable? |
|---|---|---|---|
IDENTITY_RECOVERED | An 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_HELD | The 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_INVALID | The 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_LOCKED | Five 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_REQUIRED | Speeding 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_ACTIVE | The hold was already released, sped up or cancelled | "HOLD_NOT_ACTIVE: hold already cancelled", "HOLD_NOT_ACTIVE: hold already released" and variants | No |
CHECK_NOT_ENABLED, INSUFFICIENT_CREDITS, QUOTE_EXPIRED, HOLD_REJECTED, PRICE_TOO_HIGH, UPLOAD_REJECTED, RATE_LIMITED, CHARGE_PENDING, CHECK_UNAVAILABLE | Wallet check (checkWallet and companions). Mapped from the check API's {error} codes and the Plans holdService refusals, not from classifyRejectionMessage | See Check a wallet | Per 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:
- Resubmitting byte-identical signed bytes is safe and gets deduped.
$expireis 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-signedtxobject 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. - A caller-level retry that rebuilds the transaction is NOT safe if it
restamps
$expire(or regeneratesorder.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 callsend()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.errorspresent and$summary.commit < 1, i.e. nothing committed anywhere) is thrown asLedgerRejectionand 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.sendToAnyNodecorrectly treats this as success (returns the body rather than throwing) precisely because it checkscommit, not "were there any errors at all." A port that naively throws onerrors.length > 0will report a successful transfer as a failure and invite exactly the unsafe retry described above.
7.2 Per-method retry-safety summary
| Method | Safe to retry (same call, transport failure) | Safe to call again with fresh inputs after an unknown outcome |
|---|---|---|
send, transferOnLedger, confirmTransfer, fillOrder, matchOrders, cancelOrder, key changes | Yes — sendToAnyNode already does this internally | No — 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 |
submitSwap | Yes (same reason) | No, same caveat — but note buildSwap/signSwap themselves are pure local computation with no network I/O and are trivially re-runnable |
createOrder | N/A — no network call | Yes — it's a pure local signing operation; each call produces a different order (fresh salt), which is fine since nothing has been submitted yet |
postOrder | Already fans out to every gateway and succeeds if any one accepts (varnir-client.ts:1622-1632) — designed to be over-delivered | Yes — posting the same signed order twice is idempotent from the contract's point of view (same orderHash) |
marketOrder | Internally retries across candidates on a detected race (varnir-client.ts:1838-1852), not across nodes for the same fill | Depends 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, andgatewayGet/gatewayPost(gateway-get.ts) for reads against a minigate gateway. Both use only globalfetch/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 whatVarnirClient(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-nodeand@activeledger/sdk-webrespectively, kept only because some existing TypeScript consumers (server-side Node processes vs. browser/React Native) need the official SDK'sConnection/KeyHandler/TransactionHandlersurface for reasons specific to those environments (see each file's own doc comment — e.g.web-transport.ts:1-6on whysdk-webavoids 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 assendToAnyNodedoes.
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
VALIDATIONbefore anything is sent. - Result. An L1 withdrawal that moved money (no
proposal) reportsspeed= the given value, oreconomywhen 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/speedwith 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 tagPOST /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
speedRecordedis true on a 2xx, false otherwise, and absent when no speed was passed. - Vector:
send.toExternalAddress.speedinrequests.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-clientas read at the commits cited throughout (thegit loghead at time of writing includesb146b15,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:
- A
domainfield's implicit version (already present on resting orders:{namespace, contract}pins an order to one deployment). Extendingdomainwith aspecVersion(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. - A per-network
signingSchemeadvertised inVarnirNetworkConfig(networks.ts), read by the SDK before building any transaction, so a port can assertif (config.signingScheme !== 'v1') throwrather than silently producing v1-shaped bytes against a v2-only contract deployment.
- A
- 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.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.
- §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. - §6 — RESOLVED. The engine-level expiry wording is confirmed and is in
the
EXPIREDrow: 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.classifyRejectionMessagematches the shared"signature expired"half, before theNOT_OWNERblock, andexpiry-error-mapping.test.tspins 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. - §6: several rejection strings are ambiguous between two codes by
design (e.g.
"Approved Already"could beALREADY_SETTLEDorTREASURY_GATED) — this document picked one bucket per message and stated the disambiguating rule (§6,ALREADY_SETTLEDrow's preamble), but a stricter spec might want a secondarysubcodefor these instead of forcing a single bucket. - Line-number citations drift. Every
varnir-client.ts:NNNcitation is "as of this document's own commit" and should be re-verified, not assumed, before a port relies on it —varnir-client.tshas 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. - §2.5: whether the ledger's umid is derived from
$txalone or from the whole envelope including$sigscould not be confirmed — the computation lives in ActiveLedger core, which is not a dependency of this repository. The installed@activeledger/*packages areactivecontracts,activecrypto,activedefinitions,activelogger,activequery,activetoolkits,activeutilities,httpdand the threesdk-*packages; none of them computes a umid. It did not matter while every signature was deterministic, because a re-signed identical$txproduced identical$sigstoo; with a randomised post-quantum signature it decides whether "rebuild the same$txand re-sign it" is deduped or executed twice. Whoever next has a live node should submit one$txsigned 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.