Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
85 changes: 43 additions & 42 deletions src/utils/auth/build-auth-payload.ts
Original file line number Diff line number Diff line change
@@ -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<Condition>`
* (`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,
Expand All @@ -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<Condition>::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
Expand Down
77 changes: 77 additions & 0 deletions src/utils/auth/build-auth-payload.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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);
});
});
Loading