Skip to content

Emit prepared_transaction envelope on /transfer/prepare and /transfer/incoming/{id}/prepare #275

@sadiq1971

Description

@sadiq1971

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

  • POST /api/v2/transfer/prepare response includes prepared_transaction.
  • POST /api/v2/transfer/incoming/{contractId}/prepare response includes prepared_transaction.
  • prepared_transaction.transactionHash round-trips: 0x1220 || sha256(canonicalJson(envelope)) recomputes to the same value.
  • Unit tests cover envelope shape for both prepare paths.
  • Cross-implementation test vectors fixture (committed in this repo, also referenced by canton-snap test suite) — small set of envelopes (ASCII, non-ASCII recipient/sender, empty details, full details, optional fields present/absent) with their expected canonical bytes and multihash. The snap's vitest should be able to consume the same vectors.
  • Roundtrip integration: the dev stack + the snap (built from canton-snap PR 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

  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions