From 7070209a38ff44f714522849be91099c0911ddd8 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 8 Jul 2026 10:45:03 -0300 Subject: [PATCH] fix(auth): mirror soroban-core #38 injective hash_payload encoding (A1) Replace the legacy per-type bucketed concatenation in buildAuthPayloadHash with the byte-identical encoding used by soroban-core's hash_payload (modules/primitives/src/lib.rs, PR #38, A1): contractId (strkey bytes) ++ ToXdr(Vec) ++ live_until_ledger (4-byte LE) hashed with SHA-256 by the P256 signer. Conditions are serialized via their canonical ScVal-vector XDR in input order (never sorted/bucketed), so ExtDeposit(X,a) and ExtWithdraw(X,a) over the same address/amount now produce distinct digests. Off-chain signatures must match this exactly or on-chain verification fails once #38 merges. Add a known-answer cross-check test asserting the SDK digest equals the contract's hash_payload output for fixed vectors (deposit, withdraw, and a mixed deposit+create list), including deposit != withdraw. Patch bump 0.6.1 -> 0.6.2. --- deno.json | 2 +- src/utils/auth/build-auth-payload.ts | 85 ++++++++++--------- .../auth/build-auth-payload.unit.test.ts | 77 +++++++++++++++++ 3 files changed, 121 insertions(+), 43 deletions(-) create mode 100644 src/utils/auth/build-auth-payload.unit.test.ts diff --git a/deno.json b/deno.json index 8fff17c..ebeaf10 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight/moonlight-sdk", - "version": "0.6.1", + "version": "0.6.2", "description": "A privacy-focused toolkit for the Moonlight protocol on Stellar Soroban smart contracts.", "license": "MIT", "tasks": { diff --git a/src/utils/auth/build-auth-payload.ts b/src/utils/auth/build-auth-payload.ts index f6e59f4..5593f59 100644 --- a/src/utils/auth/build-auth-payload.ts +++ b/src/utils/auth/build-auth-payload.ts @@ -1,11 +1,31 @@ import { Buffer } from "node:buffer"; -import type { - Condition, - CreateCondition, - DepositCondition, - WithdrawCondition, -} from "../../conditions/types.ts"; +import { xdr } from "@stellar/stellar-sdk"; +import type { Condition } from "../../conditions/types.ts"; +/** + * Builds the pre-image the P256 signer signs over for a bundle of conditions. + * + * This MUST stay byte-identical to soroban-core's `hash_payload` + * (`modules/primitives/src/lib.rs`). The contract builds, in this fixed order: + * + * 1. the caller contract address as its strkey string bytes + * (`caller_contract.to_string().to_bytes()`), + * 2. the canonical XDR encoding of the ordered `Vec` + * (`conditions.to_xdr(e)`), + * 3. the `live_until_ledger` as a 4-byte little-endian `u32`, + * + * then hashes the result with SHA-256 for signature verification. The signer + * (`crypto.subtle.sign` with ECDSA/SHA-256) applies the SHA-256 itself, so this + * function returns the un-hashed pre-image. + * + * The condition list is serialized with XDR — the same self-delimiting on-wire + * representation the contract compares against. Because XDR length-prefixes + * vectors and tags every enum variant, the encoding is injective: distinct or + * re-ordered condition lists can never produce the same bytes (an + * `ExtDeposit(X, a)` and an `ExtWithdraw(X, a)` over the same address and amount + * hash differently). Order is preserved exactly as given — conditions are never + * sorted, bucketed, or canonicalized. + */ export const buildAuthPayloadHash = ({ contractId, conditions, @@ -17,47 +37,28 @@ export const buildAuthPayloadHash = ({ }): Uint8Array => { const encoder = new TextEncoder(); + // 1. Contract address bytes: the strkey string as UTF-8 bytes, matching the + // contract's `caller_contract.to_string().to_bytes()`. const encodedContractId = encoder.encode(contractId); - const parts: Uint8Array[] = [encodedContractId]; - const createConditions: CreateCondition[] = []; - const depositConditions: DepositCondition[] = []; - const withdrawConditions: WithdrawCondition[] = []; - - for (const condition of conditions) { - if (condition.isCreate()) { - createConditions.push(condition); - } else if (condition.isDeposit()) { - depositConditions.push(condition); - } else if (condition.isWithdraw()) { - withdrawConditions.push(condition); - } - } - - // CREATE - for (const createCond of createConditions) { - parts.push(new Uint8Array(createCond.getUtxo())); - const amountBytes = bigintToLE(createCond.getAmount(), 16); - parts.push(amountBytes); - } - // DEPOSIT - for (const depositCond of depositConditions) { - parts.push(encoder.encode(depositCond.getPublicKey())); - parts.push(bigintToLE(depositCond.getAmount(), 16)); - } - // WITHDRAW - for (const withdrawCond of withdrawConditions) { - parts.push(encoder.encode(withdrawCond.getPublicKey())); - parts.push(bigintToLE(withdrawCond.getAmount(), 16)); - } + // 2. Canonical, order-sensitive XDR of the condition list. Each Condition + // serializes to the same ScVal vector `[symbol, address, i128]` the + // contract's enum produces; the list is wrapped in an outer ScVal vector, + // mirroring `Vec::to_xdr`. Order is preserved as-is. + const conditionsScVal = xdr.ScVal.scvVec( + conditions.map((condition) => condition.toScVal()), + ); + const encodedConditions = new Uint8Array(conditionsScVal.toXDR()); + // 3. live_until_ledger as a 4-byte little-endian u32. const encodedLiveUntil = bigintToLE(BigInt(liveUntilLedger), 4); - parts.push(encodedLiveUntil); - - // Concatenate all parts into one Uint8Array - const payload = Buffer.concat(parts); - return payload; + // Concatenate the three parts into the signing pre-image. + return Buffer.concat([ + encodedContractId, + encodedConditions, + encodedLiveUntil, + ]); }; // Convert bigint to little endian diff --git a/src/utils/auth/build-auth-payload.unit.test.ts b/src/utils/auth/build-auth-payload.unit.test.ts new file mode 100644 index 0000000..b43fdac --- /dev/null +++ b/src/utils/auth/build-auth-payload.unit.test.ts @@ -0,0 +1,77 @@ +import { assertEquals, assertNotEquals } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import type { ContractId, Ed25519PublicKey } from "@colibri/core"; +import type { UTXOPublicKey } from "../../core/utxo-keypair-base/types.ts"; +import { Condition } from "../../conditions/index.ts"; +import type { Condition as ConditionInput } from "../../conditions/types.ts"; +import { buildAuthPayloadHash } from "./build-auth-payload.ts"; +import { sha256Buffer } from "../hash/sha256Buffer.ts"; + +/** + * Known-answer cross-check against soroban-core's `hash_payload` + * (`modules/primitives/src/lib.rs`, PR #38 — A1 injective encoding, + * head efb41c83d8b725e0423dbf5485abf40a54f2811d). + * + * The expected digests below were produced by running the contract's own + * `hash_payload` over these exact inputs (fixed strkeys / amounts / ledgers). + * The SDK builds the same pre-image and SHA-256s it here; matching digests + * prove the off-chain signer and the on-chain verifier are byte-for-byte in + * sync. If PR #38's encoding shifts before merge, regenerate these vectors. + */ + +// Fixed strkeys shared with the contract KAT (StrKey.encodeContract(0x11*32) / +// StrKey.encodeEd25519PublicKey(0x22*32)). +const CONTRACT_ID = + "CAIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRDB3V" as ContractId; +const ACCOUNT = + "GARCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCEIRCFRVX" as Ed25519PublicKey; +// Create UTXO key: BytesN<65> of 0x07, matching the contract's [7u8; 65]. +const UTXO: UTXOPublicKey = new Uint8Array(65).fill(7); + +// Ground-truth digests from soroban-core `hash_payload` (SHA-256, hex). +const KAT_DEPOSIT = + "4482986e3d9f950235296f63e6b72e4ae7ba11817fdcce77dc0533a062e75b73"; +const KAT_WITHDRAW = + "bb0354a93c12f979c2ee14a849ca85f382af5ab88e0ec23fbe8b5fc8883bc57e"; +const KAT_COMBINED = + "506acd6b0043da9aa0d3a7a6596e823fcef742eef4c1aa5122e6e31e9232faee"; + +const digestHex = async ( + conditions: ConditionInput[], + liveUntilLedger: number, +): Promise => { + const preimage = buildAuthPayloadHash({ + contractId: CONTRACT_ID, + conditions, + liveUntilLedger, + }); + const digest = new Uint8Array(await sha256Buffer(preimage)); + return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join(""); +}; + +describe("buildAuthPayloadHash — cross-check with soroban-core hash_payload", () => { + it("matches the contract digest for a single ExtDeposit", async () => { + const conditions = [Condition.deposit(ACCOUNT, 1000n)]; + assertEquals(await digestHex(conditions, 100), KAT_DEPOSIT); + }); + + it("matches the contract digest for a single ExtWithdraw", async () => { + const conditions = [Condition.withdraw(ACCOUNT, 1000n)]; + assertEquals(await digestHex(conditions, 100), KAT_WITHDRAW); + }); + + it("hashes ExtDeposit(X,a) and ExtWithdraw(X,a) differently (A1)", async () => { + const deposit = await digestHex([Condition.deposit(ACCOUNT, 1000n)], 100); + const withdraw = await digestHex([Condition.withdraw(ACCOUNT, 1000n)], 100); + // The whole point of A1: same address + amount must not share a digest. + assertNotEquals(deposit, withdraw); + }); + + it("matches the contract digest for a mixed Deposit+Create list", async () => { + const conditions = [ + Condition.deposit(ACCOUNT, 42n), + Condition.create(UTXO, 99n), + ]; + assertEquals(await digestHex(conditions, 555), KAT_COMBINED); + }); +});