feat!: v3 — chain_id in signing, bigint amounts, unsigned-tx verification, Burn - #4
Merged
Conversation
…ication, Burn
- signTransaction now RLP-encodes chain_id at index 2 in both the hash
preimage (4-item list) and the full signed payload (8-item list),
matching clutch-node's Plan A wire format byte-for-byte.
- Add verifyUnsignedTransaction(unsignedTx, expected): pure, exported.
signTransaction calls it when given `expected`, closing the
blind-signing hole where a compromised hub could alter the fare, swap
the chain_id, or hand back a mismatched tx type. chain_id is checked
with strict equality (not presence-only) against a client-pinned
value, never the hub's own chainInfo response. The hub-injected
referrer cannot yet be verified (needs a future signed-quote flow) so
it is returned in VerifiedTx.referrer for the caller to display
pre-sign as an interim mitigation.
- Add Burn (RLP tag 7) support: encodeFunctionCall case, and
createUnsignedBurn mutation wrapper.
- Auth challenge is now chain-bound: clutch-auth:{chainId}:{publicKey}:
{timestamp}. Verified byte-for-byte against clutch-hub-api's own
Rust test fixtures in auth.rs.
- Add getAuthHeaders() for callers that need this SDK's JWT outside
its own GraphQL calls.
- Add formatUsd(microUsd: bigint): string for integer-only $X.XX
display.
- Add explicit .js extensions to relative imports in src/index.ts and
src/sdk.ts so dist/ is loadable under Node ESM (tcs emits
extensionless specifiers otherwise, which ERR_MODULE_NOT_FOUNDs
without bundler resolution).
BREAKING CHANGE: signTransaction's hash preimage and signed payload
both gained chain_id (inserted after nonce; everything after it shifts
by one index). fare/amount/balance public types moved from number to
bigint; the corresponding GraphQL mutation variables changed from
Int to String. buildAuthChallengeMessage/authChallengeHashHex/
signAuthChallenge gained a required leading chainId parameter and the
auth challenge string format changed — no fallback to the old
two-field format. Requires clutch-node treasury-break and a hub-api
build with chainInfo/createUnsignedBurn. The orchestrator REST client
described in the task brief was deliberately not built: it targets a
payment-orchestrator service that does not exist yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
signTransaction(.., expected) previously ran every check EXCEPT the chain_id pin when no chainId was configured, on the reasoning that nothing had been pinned. That is the worst available outcome: the caller believes the transaction was validated while the one check that stops a cross-chain replay quietly did not run - the same hole chain_id was added to close. It now throws, naming the constructor argument. Nearly unreachable in practice, since ensureAuth already needs the real chainId for the chain-bound challenge, so an unconfigured SDK cannot get a token at all; this converts that confusing downstream auth failure into a precise message. The self-check now pins the behaviour, and its own SDK construction was the first caller the change caught. BREAKING CHANGE: verifying an unsigned transaction now requires a chainId pinned via the ClutchHubSdk constructor or expected.chainId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
Author
|
🎉 This PR is included in version 3.0.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion to clutchprotocol/clutch-node#9 and clutchprotocol/clutch-hub-api#3. Merge last — merging this to
mainpublishes v3.0.0 to npm via semantic-release, so the node and hub should be in place first.Wire format
The node moved its hash preimage to the 4-item RLP list
[from (no 0x), nonce, chain_id, data]and its signed payload to the 8-item list[from, nonce, chain_id, r, s, v, hash, data],chain_idat index 2.chain_idis encoded as minimal big-endian to match Rust'su64append —2077is82 08 1d, byte-verified in the self-check rather than merely round-tripped, because a round-trip would pass even if both sides were wrong together.Amounts become bigint
fare,farePaid,amountand balances arebigint; GraphQL variables areString.JSON.parsesilently rounds integers above 2^53. At the peg of 1 USD = 1,000,000 CLT anumberfare was already lossy before signing — the user would sign one amount and believe they had approved another. GraphQL'sIntcompounded it by being 32-bit, overflowing at roughly a $2,147 fare.rlpacceptsbigintdirectly, so values pass through unconverted — RLP-encoding a decimal string would encode its UTF-8 text rather than the integer.Also adds
formatUsd(microUsd)(integer math, no floats) so UIs can show$5.00instead of5000000.The security fix: stop signing blindly
signTransactionpreviously signed whatever blob the hub returned, checking nothing. The hub is the untrusted party in this design — the key never leaves the client because the hub isn't trusted — yet it could alter the fare, swap the referrer, or hand back another chain's id, and this SDK would sign it. Client-side signing protected the key but not the user.verifyUnsignedTransaction(unsignedTx, expected)now checksfrom,function_call_type, fare/amount (BigInt-parsed), tx-hash references, and the chain id — throwing before anything is signed.Two details that matter more than they look:
chainInfo. Asking the untrusted party which chain it is defeats the check entirely.chainIdwas configured, reasoning that nothing had been pinned. That quietly recreated the exact holechain_idwas added to close: every check runs except the one that stops a cross-chain replay, while the caller believes the transaction was validated. It now throws. (Nearly unreachable anyway, sinceensureAuthneeds the real chain id for the chain-bound challenge — so this mostly turns a confusing downstream auth failure into a precise message.)The hub-injected
referrercannot be verified without the signed-quote flow a later plan adds, so verification returns it for the UI to display before signing. That display is the interim mitigation, not a fix.Also
Burn(RLP tag 7) with its optionalredemption_ref, empty-string convention included, pluscreateUnsignedBurn.clutch-auth:{chainId}:{publicKey}:{timestamp}, matched byte-for-byte against the hub's committed Rust fixtures. Previously a challenge signed on testnet authenticated the same key against any other Clutch hub inside the ±120s window.distis loadable under Node ESM again — relative imports gained.jsextensions.tscemits extensionless specifiers and there is no"type": "module", soimport './dist/index.js'failed withERR_MODULE_NOT_FOUND; the repo's own ad-hoc check scripts had rotted for this reason.Verification
npm run build && node test_wire_v3.mjs→wire v3 self-check OK. It asserts the 8-item layout, thechain_idbytes, Burn tag 7 with and without a ref, a tampered fare throwing, a mismatched chain id throwing, fail-closed on an unpinned chain, andformatUsdcases.Not covered here: a live cross-check against a running node/hub, which needs a funded faucet on a running stack — that happens in the full-stack smoke. The auth fixtures are a real cross-language match against the hub's Rust, which is the strongest check available without the stack.
Known, deliberately not changed
diststill emits aMODULE_TYPELESS_PACKAGE_JSONwarning. Adding"type": "module"would change module resolution for every consumer of a published package (breaking CJSrequire), which deserves its own decision rather than riding along here.🤖 Generated with Claude Code