ChainSafe / ChainSafe/canton-middleware
Emit prepared_transaction envelope on /transfer/prepare and /transfer/incoming/{id}/prepare
- Dominant language
- Go
- Stars
- 1
- Forks
- 1
- Avg merge
- 40m
- Merged PRs (30d)
- 1
Description
## 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 https://github.com/ChainSafe/canton-snap/pull/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`):
```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:
```json
{
\"transfer_id\": \"...\",
\"transaction_hash\": \"0x...\",
\"party_id\": \"...\",
\"expires_at\": \"2026-05-19T13:00:00Z\",
\"prepared_transaction\": {
\"schema\": \"canton-snap.prepared-transaction.v1\",
\"transactionHash\": \"1220\",
\"operation\": \"Transfer\" | \"Accept transfer\",
\"tokenSymbol\": \"DEMO\",
\"amount\": \"100\",
\"recipient\": \"\",
\"sender\": \"\",
\"network\": \"devnet\",
\"transferId\": \"...\",
\"expiresAt\": \"2026-05-19T13:00:00Z\",
\"partyId\": \"\",
\"details\": { \"\": \"\" }
}
}
```
`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 #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.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.