Skip to content
Open
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.11.3",
"version": "0.11.4",
"description": "A privacy-focused toolkit for the Moonlight protocol on Stellar Soroban smart contracts.",
"license": "MIT",
"tasks": {
Expand Down
93 changes: 39 additions & 54 deletions src/utils/auth/build-auth-payload.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
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";
import { bigintToLE } from "../conversion/bigintToLE.ts";

/**
* Builds the signed-payload preimage that the on-chain `hash_payload`
* (soroban-core `moonlight-primitives`) hashes with SHA-256 to produce the
* digest the UTXO signature is verified against.
*
* The preimage is the concatenation, in this fixed order, of:
* 1. the contract id strkey bytes (as passed in),
* 2. the canonical XDR encoding of the ordered `Vec<Condition>` (`ScVal::Vec`
* of each condition's `ScVal`, i.e. Soroban's `ToXdr`),
* 3. the `liveUntilLedger` as a 4-byte little-endian `u32`.
*
* The condition list is serialized with XDR — the same self-delimiting on-wire
* representation the contract uses. XDR length-prefixes vectors and tags every
* enum variant, so the encoding is injective: an `ExtDeposit(X, a)` and an
* `ExtWithdraw(X, a)` over the same address and amount hash differently, and
* reordering the conditions changes the digest. Order is therefore preserved
* exactly as given — conditions are never sorted or bucketed.
*
* NOTE: this returns the preimage, not its SHA-256. The P256 signer
* (`crypto.subtle.sign` with `hash: SHA-256`) hashes the preimage internally,
* mirroring the contract's `hash_payload` SHA-256 step.
*/
export const buildAuthPayloadHash = ({
contractId,
conditions,
Expand All @@ -17,56 +36,22 @@ export const buildAuthPayloadHash = ({
}): Uint8Array => {
const encoder = new TextEncoder();

// 1. Contract id strkey bytes, matching hash_payload's leading `contract: &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 — byte-for-byte the
// `ToXdr(Vec<Condition>)` the contract appends. Order is preserved as-is.
const conditionsScVal = xdr.ScVal.scvVec(
conditions.map((condition) => condition.toScVal()),
);
const encodedConditions = 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;
return Buffer.concat([
encodedContractId,
encodedConditions,
encodedLiveUntil,
]);
};

// Convert bigint to little endian
export function bigintToLE(amount: bigint, byteLength: number): Uint8Array {
const result = new Uint8Array(byteLength);
let temp = amount;
for (let i = 0; i < byteLength; i++) {
result[i] = Number(temp & BigInt(0xff));
temp = temp >> BigInt(8);
}
return result;
}
89 changes: 89 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,89 @@
import { assertEquals, assertNotEquals } from "@std/assert";
import { describe, it } from "@std/testing/bdd";
import { Buffer } from "node:buffer";
import { Condition } from "../../conditions/index.ts";
import type { Condition as ConditionType } from "../../conditions/types.ts";
import { buildAuthPayloadHash } from "./build-auth-payload.ts";
import { sha256Buffer } from "../hash/sha256Buffer.ts";

/**
* A1 encoding lockstep with soroban-core `moonlight-primitives::hash_payload`
* (PR #38, `fix/b3-hash-payload-docstring` @ 97922db).
*
* The digest the contract verifies is
* SHA-256( contractId_bytes ++ ToXdr(Vec<Condition>) ++ live_until_ledger_LE ).
* The SDK builds the preimage; the P256 signer applies the SHA-256. The vectors
* below are the exact `hash_payload(...).to_bytes()` outputs produced by the Rust
* contract for these fixed inputs, so `SHA-256(buildAuthPayloadHash(...))` must
* reproduce them byte-for-byte.
*/

// Fixed strkeys shared verbatim with the Rust known-answer test.
const CONTRACT_ID = "CBSGKZTHNBUWU23MNVXG64DROJZXI5LWO54HS6T3PR6X474AQGBIHDKP";
const ADDRESS = "GAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYPSABOV";

// Known-answer digests emitted by soroban-core `hash_payload` for the same inputs.
const KAT = {
// [ExtDeposit(ADDRESS, 1000)], live_until_ledger = 100
deposit: "0224255e3cf3bce91f9d0a1dc125cbc1667ed310eb07bd71341168e8beeb046c",
// [ExtWithdraw(ADDRESS, 1000)], live_until_ledger = 100
withdraw: "939cf8d8ba52e468a1e3ad762bce1ea9f0759b194d931d238e776249e1230b2c",
// [ExtDeposit(ADDRESS, 1000), ExtWithdraw(ADDRESS, 2000)], live_until_ledger = 777
both: "ab6f0370f0c6c3bc5ddce845627d9436ee2251dcaf0b3095bf92faf35b6ad7f9",
} as const;

async function sdkDigestHex(
conditions: ConditionType[],
liveUntilLedger: number,
): Promise<string> {
const preimage = buildAuthPayloadHash({
contractId: CONTRACT_ID,
conditions,
liveUntilLedger,
});
return Buffer.from(await sha256Buffer(preimage)).toString("hex");
}

describe("buildAuthPayloadHash — A1 encoding (soroban-core #38 lockstep)", () => {
it("SDK digest == contract digest for a single deposit", async () => {
const deposit = Condition.deposit(ADDRESS, 1000n);
assertEquals(await sdkDigestHex([deposit], 100), KAT.deposit);
});

it("SDK digest == contract digest for a single withdraw", async () => {
const withdraw = Condition.withdraw(ADDRESS, 1000n);
assertEquals(await sdkDigestHex([withdraw], 100), KAT.withdraw);
});

it("deposit and withdraw over the same (address, amount) hash differently", async () => {
const deposit = Condition.deposit(ADDRESS, 1000n);
const withdraw = Condition.withdraw(ADDRESS, 1000n);
const depositDigest = await sdkDigestHex([deposit], 100);
const withdrawDigest = await sdkDigestHex([withdraw], 100);

// The A1 fix: the variant-tagged XDR closes the deposit/withdraw collision.
assertNotEquals(depositDigest, withdrawDigest);
assertEquals(depositDigest, KAT.deposit);
assertEquals(withdrawDigest, KAT.withdraw);
});

it("SDK digest == contract digest for an ordered multi-condition payload", async () => {
const conditions = [
Condition.deposit(ADDRESS, 1000n),
Condition.withdraw(ADDRESS, 2000n),
];
assertEquals(await sdkDigestHex(conditions, 777), KAT.both);
});

it("reordering the conditions changes the digest", async () => {
const forward = [
Condition.deposit(ADDRESS, 1000n),
Condition.withdraw(ADDRESS, 2000n),
];
const reversed = [forward[1], forward[0]];
assertNotEquals(
await sdkDigestHex(forward, 777),
await sdkDigestHex(reversed, 777),
);
});
});
Loading