Intents are hashed and signed over a canonical serialization so the same logical intent always produces the same bytes, regardless of key order or formatting. Without this, two semantically identical intents could hash differently and a signature could not be verified server-side.
Source of truth: lib/intent/ (hash.ts, sign.ts,
envelope.ts) and types/intent.ts.
Given the intent object:
- Sort object keys recursively — every object's keys in ascending (code-point) order, applied at every nesting level.
- Serialize with
JSON.stringifyover the key-sorted structure — no extra whitespace, standard JSON escaping. - The resulting UTF-8 bytes are the canonical payload.
Arrays preserve their order (order is semantically meaningful); only object keys are sorted.
hash = lowercase_hex( SHA-256( canonical_bytes ) ) // 64 hex chars
This hash is included in the signed envelope and the server recomputes it from
the received intent. A mismatch rejects the request before signature checking.
signature = base64( Ed25519_sign( canonical_bytes, userPrivateKey ) )
Signing happens in the user's wallet (Freighter); the private key never leaves the
wallet. The server verifies the signature against the envelope's publicKey, which
must equal intent.publicKey.
Implementation note: treat
lib/intentas authoritative for the exact ordering and serialization, and verify against its tests rather than hand-ordering keys.
docs/INTENT_API.md— the envelope and endpoint.docs/THREAT_MODEL.md— replay/tamper mitigations.