Context
canton-snap is moving canton_signHash from blind hash signing to envelope-verified signing. The snap will re-derive the SHA-256 multihash from a canonical-JSON envelope returned by the middleware and refuse to sign if the recomputed hash doesn't match — eliminating the class of attacks where a malicious dApp shows the user one transaction but submits another for signing.
The snap-side enforcement was reverted in ChainSafe/canton-snap#52 (commit 3fff26a) because no released middleware build currently emits the envelope, which broke local dev. Snap-side reintroduction is tracked in canton-snap#53. This issue tracks the middleware-side work.
What to change
Both prepare endpoints currently return PrepareResponse (pkg/transfer/types.go):
type PrepareResponse struct {
TransferID string `json:"transfer_id"`
TransactionHash string `json:"transaction_hash"`
PartyID string `json:"party_id"`
ExpiresAt string `json:"expires_at"`
}
Add a PreparedTransaction *PreparedTransaction \json:"prepared_transaction"`` field (always populated), so the response becomes:
{
\"transfer_id\": \"...\",
\"transaction_hash\": \"0x...\",
\"party_id\": \"...\",
\"expires_at\": \"2026-05-19T13:00:00Z\",
\"prepared_transaction\": {
\"schema\": \"canton-snap.prepared-transaction.v1\",
\"transactionHash\": \"1220<sha256 of canonicalJson(envelope)>\",
\"operation\": \"Transfer\" | \"Accept transfer\",
\"tokenSymbol\": \"DEMO\",
\"amount\": \"100\",
\"recipient\": \"<canton party id>\",
\"sender\": \"<canton party id>\",
\"network\": \"devnet\",
\"transferId\": \"...\",
\"expiresAt\": \"2026-05-19T13:00:00Z\",
\"partyId\": \"<sender canton party id>\",
\"details\": { \"<extra>\": \"<string>\" }
}
}
transactionHash MUST equal 0x1220 || sha256(canonicalJson(envelope)), where canonicalJson is the algorithm below and the envelope is everything in the object except transactionHash itself.
Affected call sites:
pkg/transfer/service.go:130 TransferService.Prepare — populate envelope from the PrepareTransferRequest plus the resolved party IDs.
pkg/transfer/service.go:307 TransferService.PrepareAccept — populate envelope from the offer's instrument/amount/parties (read from the contract or from the existing pt value).
pkg/transfer/http.go — no handler-level changes if the service returns the full envelope.
pkg/transfer/types.go — add the PreparedTransaction Go type and embed it in PrepareResponse.
Canonical-JSON algorithm (must be byte-identical to the snap)
Implementing this in Go: write a custom encoder, do not rely on encoding/json's default field ordering or HTML escaping.
- Object keys sorted by Unicode codepoint (
sort.Strings on keys; matches JS Array#sort).
- No whitespace; no trailing commas anywhere.
- Strings serialized per ECMA-262
JSON.stringify: escape \\ \" \\b \\f \\n \\r \\t, and any other control char < 0x20 as \\u00XX. Non-ASCII codepoints emitted as literal UTF-8 (no \\uXXXX).
- Do not escape
< > & (Go's default encoding/json does — turn this off via Encoder.SetEscapeHTML(false) or write the escape table by hand).
- Numbers: not used in the envelope today (every field is a string). Reject
NaN/±Inf if added later.
- Booleans/null:
true, false, null.
- Arrays:
[elem,elem,...].
Bounds (the snap rejects exceeding any of these):
details map ≤ 20 entries.
- Every string ≤ 200 chars.
- Canonical envelope ≤ 64 KiB.
The exact JS reference implementation is at packages/snap/src/validation.ts (function canonicalJson, with the spec block in the file header comment) — see https://github.com/ChainSafe/canton-snap/blob/5fe8bc4/packages/snap/src/validation.ts#L73-L91.
Acceptance criteria
Why this matters
Without this, a malicious dApp can show the user "Send 1 DEMO to alice" in its UI, hand the snap a hash that actually represents "Send 10000 DEMO to attacker," and the snap has no way to detect the divergence. The envelope makes the dialog content cryptographically bound to the signed digest.
Coordinating the change
- canton-snap: reintroduces the snap-side verifier (canton-snap#53).
- dApp: switches its prepare-response parser to require
prepared_transaction and pass it through to canton_signHash (in the same canton-snap PR).
- Both land together once this middleware change is released. Until then, the snap stays on the hash+metadata interface so local dev continues to work.
Context
canton-snapis movingcanton_signHashfrom blind hash signing to envelope-verified signing. The snap will re-derive the SHA-256 multihash from a canonical-JSON envelope returned by the middleware and refuse to sign if the recomputed hash doesn't match — eliminating the class of attacks where a malicious dApp shows the user one transaction but submits another for signing.The snap-side enforcement was reverted in ChainSafe/canton-snap#52 (commit
3fff26a) because no released middleware build currently emits the envelope, which broke local dev. Snap-side reintroduction is tracked in canton-snap#53. This issue tracks the middleware-side work.What to change
Both prepare endpoints currently return
PrepareResponse(pkg/transfer/types.go):Add a
PreparedTransaction *PreparedTransaction \json:"prepared_transaction"`` field (always populated), so the response becomes:{ \"transfer_id\": \"...\", \"transaction_hash\": \"0x...\", \"party_id\": \"...\", \"expires_at\": \"2026-05-19T13:00:00Z\", \"prepared_transaction\": { \"schema\": \"canton-snap.prepared-transaction.v1\", \"transactionHash\": \"1220<sha256 of canonicalJson(envelope)>\", \"operation\": \"Transfer\" | \"Accept transfer\", \"tokenSymbol\": \"DEMO\", \"amount\": \"100\", \"recipient\": \"<canton party id>\", \"sender\": \"<canton party id>\", \"network\": \"devnet\", \"transferId\": \"...\", \"expiresAt\": \"2026-05-19T13:00:00Z\", \"partyId\": \"<sender canton party id>\", \"details\": { \"<extra>\": \"<string>\" } } }transactionHashMUST equal0x1220 || sha256(canonicalJson(envelope)), wherecanonicalJsonis the algorithm below and the envelope is everything in the object excepttransactionHashitself.Affected call sites:
pkg/transfer/service.go:130 TransferService.Prepare— populate envelope from thePrepareTransferRequestplus the resolved party IDs.pkg/transfer/service.go:307 TransferService.PrepareAccept— populate envelope from the offer's instrument/amount/parties (read from the contract or from the existingptvalue).pkg/transfer/http.go— no handler-level changes if the service returns the full envelope.pkg/transfer/types.go— add thePreparedTransactionGo type and embed it inPrepareResponse.Canonical-JSON algorithm (must be byte-identical to the snap)
Implementing this in Go: write a custom encoder, do not rely on
encoding/json's default field ordering or HTML escaping.sort.Stringson keys; matches JSArray#sort).JSON.stringify: escape\\\"\\b\\f\\n\\r\\t, and any other control char< 0x20as\\u00XX. Non-ASCII codepoints emitted as literal UTF-8 (no\\uXXXX).<>&(Go's defaultencoding/jsondoes — turn this off viaEncoder.SetEscapeHTML(false)or write the escape table by hand).NaN/±Infif added later.true,false,null.[elem,elem,...].Bounds (the snap rejects exceeding any of these):
detailsmap ≤ 20 entries.The exact JS reference implementation is at
packages/snap/src/validation.ts(functioncanonicalJson, with the spec block in the file header comment) — see https://github.com/ChainSafe/canton-snap/blob/5fe8bc4/packages/snap/src/validation.ts#L73-L91.Acceptance criteria
POST /api/v2/transfer/prepareresponse includesprepared_transaction.POST /api/v2/transfer/incoming/{contractId}/prepareresponse includesprepared_transaction.prepared_transaction.transactionHashround-trips:0x1220 || sha256(canonicalJson(envelope))recomputes to the same value.canton-snapPR for issue feat: Custodial key management for Canton user registration #53) completes a transfer end-to-end with no "transactionHash does not match canonical transaction data" rejections.Why this matters
Without this, a malicious dApp can show the user "Send 1 DEMO to alice" in its UI, hand the snap a hash that actually represents "Send 10000 DEMO to attacker," and the snap has no way to detect the divergence. The envelope makes the dialog content cryptographically bound to the signed digest.
Coordinating the change
prepared_transactionand pass it through tocanton_signHash(in the same canton-snap PR).