diff --git a/src/index.ts b/src/index.ts index ebf7e30..7d8259a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -496,7 +496,7 @@ export { getSuggestion } from "./errorSuggestions.js"; // XDR Decoder — structured logging of Stellar XDR // --------------------------------------------------------------------------- -export { decodeXDR } from "./xdrDecoder.js"; +export { decodeXDR, decode, decodeInt128 } from "./xdrDecoder.js"; export { decodeTransactionResult } from "./txResultDecoder.js"; // --------------------------------------------------------------------------- @@ -1012,7 +1012,7 @@ export type { // Trustline checker // --------------------------------------------------------------------------- -export { checkTrustlines, checkSingleTrustline } from "./trustlineChecker.js"; +export { checkTrustlines, checkSingleTrustline, checkTrustlinesBatch } from "./trustlineChecker.js"; export type { TrustlineEntry, TrustlineCheckResult } from "./trustlineChecker.js"; // --------------------------------------------------------------------------- diff --git a/src/merkle.ts b/src/merkle.ts index 4d71c47..184b370 100644 --- a/src/merkle.ts +++ b/src/merkle.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import { Invoice, Payment } from "./types.js"; /** @@ -6,58 +7,156 @@ import { Invoice, Payment } from "./types.js"; export interface MerkleProof { /** The leaf hash being proven (payment hash) */ leaf: string; - /** Sibling hashes along the path to the root */ + /** Sibling hashes along the path to the root, ordered leaf-to-root */ path: string[]; /** The Merkle root hash */ root: string; + /** Index of the leaf within the tree (used to determine sibling ordering) */ + index?: number; +} + +/** SHA-256 hex digest of a UTF-8 string. */ +function sha256Hex(data: string): string { + return createHash("sha256").update(data).digest("hex"); +} + +/** Combine two sibling hashes (in tree order) into their parent hash. */ +function hashPair(left: string, right: string): string { + return sha256Hex(left + right); +} + +/** + * Build the layers of a Merkle tree (leaves through root) from an ordered + * list of leaf hashes. Odd nodes at a layer are duplicated, matching the + * common Bitcoin-style padding scheme. + */ +function buildLayers(leaves: string[]): string[][] { + if (leaves.length === 0) { + return [[sha256Hex("")]]; + } + + const layers: string[][] = [leaves.slice()]; + let current = leaves; + + while (current.length > 1) { + const next: string[] = []; + for (let i = 0; i < current.length; i += 2) { + const left = current[i]!; + const right = i + 1 < current.length ? current[i + 1]! : current[i]!; + next.push(hashPair(left, right)); + } + layers.push(next); + current = next; + } + + return layers; } /** * Generate a Merkle proof for a specific payment within an invoice. + * + * Builds a Merkle tree over the SHA-256 hashes of every payment in the + * invoice (in order) and returns the sibling path from the target leaf up + * to the root, along with the leaf's index in the tree. + * * @param invoiceId - The invoice ID * @param paymentIndex - The index of the payment in the invoice's payments array + * @param payments - Ordered payments for the invoice (used to build the tree) * @returns A Merkle proof object */ export async function generateMerkleProof( invoiceId: string, - paymentIndex: number + paymentIndex: number, + payments: Payment[] = [], ): Promise { - // In a real implementation, this would: - // 1. Fetch the invoice from the contract - // 2. Extract all payment hashes - // 3. Build a Merkle tree from the payment hashes - // 4. Generate the proof for the specified payment index - - // For now, we'll return a mock proof - const leaf = `payment-${invoiceId}-${paymentIndex}-hash`; - const path = [ - `sibling-${invoiceId}-${paymentIndex}-1`, - `sibling-${invoiceId}-${paymentIndex}-2` - ]; - const root = `root-${invoiceId}-${paymentIndex}`; - + if (payments.length === 0) { + // Fall back to a single-leaf tree derived deterministically from the + // invoice/index when no payment list is supplied. + const leaf = sha256Hex(`payment-${invoiceId}-${paymentIndex}`); + return { leaf, path: [], root: leaf, index: 0 }; + } + + if (paymentIndex < 0 || paymentIndex >= payments.length) { + throw new Error( + `paymentIndex ${paymentIndex} is out of range for invoice ${invoiceId} (0..${payments.length - 1})`, + ); + } + + const leaves = payments.map((p, i) => + sha256Hex( + `${invoiceId}:${i}:${JSON.stringify(p, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + )}`, + ), + ); + const layers = buildLayers(leaves); + + const path: string[] = []; + let idx = paymentIndex; + for (let level = 0; level < layers.length - 1; level++) { + const layer = layers[level]!; + const isRightNode = idx % 2 === 1; + const siblingIndex = isRightNode ? idx - 1 : idx + 1; + const sibling = siblingIndex < layer.length ? layer[siblingIndex]! : layer[idx]!; + path.push(sibling); + idx = Math.floor(idx / 2); + } + + const root = layers[layers.length - 1]![0]!; + return { - leaf, + leaf: leaves[paymentIndex]!, path, - root + root, + index: paymentIndex, }; } /** - * Verify a Merkle proof against a given root hash. + * Verify a Merkle proof against its embedded root hash. + * + * Recomputes the root by combining the leaf with each sibling hash in + * `proof.path` (using `proof.index` to determine left/right ordering at + * each level) and compares the result against `proof.root`. + * * @param proof - The Merkle proof to verify * @returns true if the proof is valid, false otherwise */ export function verifyMerkleProof(proof: MerkleProof): boolean { - // In a real implementation, this would: - // 1. Recompute the root hash from the leaf and path - // 2. Compare the computed root with the provided root - - // For now, we'll do a simple validation - if (!proof.leaf || !proof.root || !Array.isArray(proof.path)) { + if (!proof || typeof proof.leaf !== "string" || typeof proof.root !== "string") { return false; } - - // Simple validation - in real implementation would compute the actual hash - return proof.leaf.length > 0 && proof.root.length > 0 && proof.path.length >= 0; + if (!Array.isArray(proof.path)) { + return false; + } + if (proof.leaf.length === 0 || proof.root.length === 0) { + return false; + } + + // No siblings: this is only valid for a single-leaf tree where the leaf + // itself is the root. + if (proof.path.length === 0) { + return proof.leaf === proof.root; + } + + let index = proof.index ?? 0; + if (index < 0) { + return false; + } + + let computed = proof.leaf; + for (const sibling of proof.path) { + if (typeof sibling !== "string" || sibling.length === 0) { + return false; + } + const isRightNode = index % 2 === 1; + computed = isRightNode ? hashPair(sibling, computed) : hashPair(computed, sibling); + index = Math.floor(index / 2); + } + + return computed === proof.root; } + +// Re-exported for callers that want to reference the Invoice type alongside +// Merkle proofs (kept for backward compatibility with existing imports). +export type { Invoice }; diff --git a/src/notificationCenter.ts b/src/notificationCenter.ts index 4ed8900..029967f 100644 --- a/src/notificationCenter.ts +++ b/src/notificationCenter.ts @@ -47,7 +47,23 @@ export class NotificationCenter extends EventEmitter { this._watchers.delete(invoiceId); } + /** + * Register a listener for an event, deduplicating by referential equality. + * Registering the same callback reference for the same event twice is a + * no-op on the second call, preventing duplicate notification deliveries. + */ on(event: NotificationEvent, listener: (...args: unknown[]) => void): this { + if (this.listeners(event).includes(listener)) { + return this; + } return super.on(event, listener); } + + /** + * Returns the number of distinct (deduplicated) subscribers registered + * for the given event type. + */ + getSubscriberCount(eventType: NotificationEvent): number { + return this.listenerCount(eventType); + } } diff --git a/src/trustlineChecker.ts b/src/trustlineChecker.ts index c6a6d41..f328127 100644 --- a/src/trustlineChecker.ts +++ b/src/trustlineChecker.ts @@ -9,7 +9,7 @@ * trustlines established. */ -import { Horizon } from "@stellar/stellar-sdk"; +import { Asset, Horizon } from "@stellar/stellar-sdk"; // --------------------------------------------------------------------------- // Types @@ -116,3 +116,55 @@ export async function checkTrustlines( entries, }; } + +/** + * Check whether a single account has established trustlines for multiple + * assets in a single Horizon account fetch. + * + * Unlike {@link checkTrustlines}, which checks N recipients against a single + * asset, this checks a single account against N assets — making exactly one + * Horizon `loadAccount` call regardless of how many assets are supplied. + * + * @param server - Horizon server instance. + * @param accountId - Stellar address whose trustlines should be checked. + * @param assets - Assets to check (native assets are always considered trusted). + * @returns A map from each asset to whether the account has a trustline for it. + */ +export async function checkTrustlinesBatch( + server: Horizon.Server, + accountId: string, + assets: Asset[], +): Promise> { + const result = new Map(); + + let balances: Horizon.HorizonApi.BalanceLine[] = []; + try { + const account = await server.loadAccount(accountId); + balances = account.balances; + } catch { + // Account not found or RPC error -- every non-native asset is untrusted. + for (const asset of assets) { + result.set(asset, asset.isNative()); + } + return result; + } + + for (const asset of assets) { + if (asset.isNative()) { + result.set(asset, true); + continue; + } + + const hasTrustline = balances.some( + (b) => + b.asset_type !== "native" && + b.asset_type !== "liquidity_pool_shares" && + (b as Horizon.HorizonApi.BalanceLineAsset).asset_code === asset.getCode() && + (b as Horizon.HorizonApi.BalanceLineAsset).asset_issuer === asset.getIssuer(), + ); + + result.set(asset, hasTrustline); + } + + return result; +} diff --git a/src/xdrDecoder.ts b/src/xdrDecoder.ts index d1801a9..cffcac2 100644 --- a/src/xdrDecoder.ts +++ b/src/xdrDecoder.ts @@ -244,6 +244,54 @@ function decodeLedgerEntry( }; } +// --------------------------------------------------------------------------- +// Scalar decoders +// --------------------------------------------------------------------------- + +/** + * Decode a raw 16-byte INT128 XDR value into a signed `BigInt`. + * + * An XDR `Int128Parts` is encoded as a 64-bit signed high word followed by a + * 64-bit unsigned low word (big-endian). The high word must be treated as + * *signed* so that negative values round-trip correctly -- treating it as + * unsigned causes negative values to decode as large positive numbers. + * + * @param buffer - 16-byte big-endian buffer containing the encoded INT128. + * @returns The decoded signed `BigInt`. + */ +export function decodeInt128(buffer: Buffer): bigint { + if (buffer.length !== 16) { + throw new Error(`INT128 buffer must be exactly 16 bytes, got ${buffer.length}`); + } + + const hi = buffer.readBigInt64BE(0); // signed high 64 bits + const lo = buffer.readBigUInt64BE(8); // unsigned low 64 bits + + // BigInt shifts/bitwise-ops operate on an infinite-precision two's + // complement representation, so combining a signed high word with an + // unsigned low word this way correctly preserves the sign of the result. + return (hi << 64n) | lo; +} + +/** + * Decode a raw XDR scalar value of the given type from a buffer. + * + * Currently supports `"INT128"`; additional scalar types can be added here + * as needed. + * + * @param type - The XDR scalar type to decode. + * @param buffer - Raw bytes for the value. + */ +export function decode(type: "INT128", buffer: Buffer): bigint; +export function decode(type: string, buffer: Buffer): bigint { + switch (type) { + case "INT128": + return decodeInt128(buffer); + default: + throw new Error(`Unsupported scalar decode type: ${type}`); + } +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- diff --git a/test/merkleVerify.test.ts b/test/merkleVerify.test.ts new file mode 100644 index 0000000..71a3c65 --- /dev/null +++ b/test/merkleVerify.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { generateMerkleProof, verifyMerkleProof, type MerkleProof } from "../src/merkle.js"; +import type { Payment } from "../src/types.js"; + +function makePayments(count: number): Payment[] { + return Array.from({ length: count }, (_, i) => ({ + payer: `GPAYER${i}`, + amount: BigInt(100 + i), + timestamp: 1_700_000_000 + i, + })) as unknown as Payment[]; +} + +describe("verifyMerkleProof", () => { + it("returns true for a valid proof of a leaf in a known tree", async () => { + const payments = makePayments(5); + const proof = await generateMerkleProof("invoice-1", 2, payments); + + expect(verifyMerkleProof(proof)).toBe(true); + }); + + it("returns true for every leaf index in a known tree", async () => { + const payments = makePayments(7); + + for (let i = 0; i < payments.length; i++) { + const proof = await generateMerkleProof("invoice-2", i, payments); + expect(verifyMerkleProof(proof)).toBe(true); + } + }); + + it("returns false for a tampered proof (wrong hash at one level)", async () => { + const payments = makePayments(5); + const proof = await generateMerkleProof("invoice-1", 2, payments); + + const tampered: MerkleProof = { + ...proof, + path: [...proof.path], + }; + // Corrupt the first sibling hash on the path. + tampered.path[0] = "0".repeat(64); + + expect(verifyMerkleProof(tampered)).toBe(false); + }); + + it("returns false when the leaf itself is tampered", async () => { + const payments = makePayments(5); + const proof = await generateMerkleProof("invoice-1", 2, payments); + + const tampered: MerkleProof = { ...proof, leaf: "f".repeat(64) }; + + expect(verifyMerkleProof(tampered)).toBe(false); + }); + + it("returns false when the root is tampered", async () => { + const payments = makePayments(5); + const proof = await generateMerkleProof("invoice-1", 2, payments); + + const tampered: MerkleProof = { ...proof, root: "a".repeat(64) }; + + expect(verifyMerkleProof(tampered)).toBe(false); + }); + + it("returns false for a leaf index out of range during generation", async () => { + const payments = makePayments(3); + + await expect(generateMerkleProof("invoice-3", 99, payments)).rejects.toThrow( + /out of range/i, + ); + await expect(generateMerkleProof("invoice-3", -1, payments)).rejects.toThrow( + /out of range/i, + ); + }); + + it("returns false for a malformed proof missing required fields", () => { + expect(verifyMerkleProof({} as MerkleProof)).toBe(false); + expect(verifyMerkleProof({ leaf: "", path: [], root: "" } as MerkleProof)).toBe(false); + expect( + verifyMerkleProof({ leaf: "abc", path: null as unknown as string[], root: "def" } as MerkleProof), + ).toBe(false); + }); + + it("validates a single-leaf tree where the leaf is the root", async () => { + const payments = makePayments(1); + const proof = await generateMerkleProof("invoice-4", 0, payments); + + expect(proof.path).toHaveLength(0); + expect(verifyMerkleProof(proof)).toBe(true); + }); +}); diff --git a/test/notificationCenter.dedup.test.ts b/test/notificationCenter.dedup.test.ts new file mode 100644 index 0000000..d6f0cd4 --- /dev/null +++ b/test/notificationCenter.dedup.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { NotificationCenter } from "../src/notificationCenter.js"; + +describe("NotificationCenter subscriber deduplication", () => { + it("registers the same callback reference only once for the same event", () => { + const center = new NotificationCenter(async () => { + throw new Error("not used in this test"); + }); + const handler = vi.fn(); + + center.on("payment", handler); + center.on("payment", handler); + center.on("payment", handler); + + expect(center.getSubscriberCount("payment")).toBe(1); + + center.emit("payment", "invoice-1", { payer: "G...", amount: 1n }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("registers different callback references independently", () => { + const center = new NotificationCenter(async () => { + throw new Error("not used in this test"); + }); + const handlerA = vi.fn(); + const handlerB = vi.fn(); + + center.on("payment", handlerA); + center.on("payment", handlerB); + + expect(center.getSubscriberCount("payment")).toBe(2); + }); + + it("tracks subscriber counts independently per event type", () => { + const center = new NotificationCenter(async () => { + throw new Error("not used in this test"); + }); + const handler = vi.fn(); + + center.on("payment", handler); + center.on("released", handler); + + expect(center.getSubscriberCount("payment")).toBe(1); + expect(center.getSubscriberCount("released")).toBe(1); + expect(center.getSubscriberCount("expired")).toBe(0); + }); +}); diff --git a/test/trustlineChecker.batch.test.ts b/test/trustlineChecker.batch.test.ts new file mode 100644 index 0000000..db5bf8b --- /dev/null +++ b/test/trustlineChecker.batch.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { Asset, Horizon } from "@stellar/stellar-sdk"; +import { checkTrustlinesBatch } from "../src/trustlineChecker.js"; + +function mockServer(balances: unknown[]): Horizon.Server { + return { + loadAccount: vi.fn().mockResolvedValue({ balances }), + } as unknown as Horizon.Server; +} + +describe("checkTrustlinesBatch", () => { + it("makes a single Horizon account fetch regardless of asset count", async () => { + const server = mockServer([]); + const assets = [ + new Asset("USDC", "GISSUERUSDC000000000000000000000000000000000000000"), + new Asset("EURT", "GISSUEREURT000000000000000000000000000000000000000"), + Asset.native(), + ]; + + await checkTrustlinesBatch(server, "GACCOUNT", assets); + + expect(server.loadAccount).toHaveBeenCalledTimes(1); + }); + + it("correctly identifies which assets have a trustline", async () => { + const usdc = new Asset("USDC", "GISSUERUSDC000000000000000000000000000000000000000"); + const eurt = new Asset("EURT", "GISSUEREURT000000000000000000000000000000000000000"); + const native = Asset.native(); + + const server = mockServer([ + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GISSUERUSDC000000000000000000000000000000000000000", + }, + ]); + + const result = await checkTrustlinesBatch(server, "GACCOUNT", [usdc, eurt, native]); + + expect(result.get(usdc)).toBe(true); + expect(result.get(eurt)).toBe(false); + expect(result.get(native)).toBe(true); + }); + + it("treats every non-native asset as untrusted when the account fetch fails", async () => { + const server = { + loadAccount: vi.fn().mockRejectedValue(new Error("not found")), + } as unknown as Horizon.Server; + const usdc = new Asset("USDC", "GISSUERUSDC000000000000000000000000000000000000000"); + const native = Asset.native(); + + const result = await checkTrustlinesBatch(server, "GMISSING", [usdc, native]); + + expect(result.get(usdc)).toBe(false); + expect(result.get(native)).toBe(true); + }); +}); diff --git a/test/xdrDecoder.int128.test.ts b/test/xdrDecoder.int128.test.ts new file mode 100644 index 0000000..446875d --- /dev/null +++ b/test/xdrDecoder.int128.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { decode, decodeInt128 } from "../src/xdrDecoder.js"; + +/** Build a 16-byte big-endian INT128 buffer from a signed BigInt. */ +function encodeInt128(value: bigint): Buffer { + const buf = Buffer.alloc(16); + const mask = (1n << 64n) - 1n; + const hi = value >> 64n; + const lo = value & mask; + buf.writeBigInt64BE(BigInt.asIntN(64, hi), 0); + buf.writeBigUInt64BE(lo, 8); + return buf; +} + +describe("decodeInt128", () => { + it("decodes -1 correctly as a negative BigInt", () => { + const buffer = encodeInt128(-1n); + expect(decodeInt128(buffer)).toBe(-1n); + }); + + it("decodes a large negative value correctly", () => { + const value = -170141183460469231731687303715884105728n; // INT128_MIN + const buffer = encodeInt128(value); + expect(decodeInt128(buffer)).toBe(value); + }); + + it("decodes a positive value correctly", () => { + const value = 123456789012345678901234567890n; + const buffer = encodeInt128(value); + expect(decodeInt128(buffer)).toBe(value); + }); + + it("decodes zero correctly", () => { + expect(decodeInt128(encodeInt128(0n))).toBe(0n); + }); + + it("throws on a buffer of the wrong length", () => { + expect(() => decodeInt128(Buffer.alloc(8))).toThrow(); + }); + + it("decode('INT128', buffer) matches decodeInt128", () => { + const buffer = encodeInt128(-42n); + expect(decode("INT128", buffer)).toBe(-42n); + expect(decode("INT128", buffer)).toBe(decodeInt128(buffer)); + }); +});