Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down
155 changes: 127 additions & 28 deletions src/merkle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "crypto";
import { Invoice, Payment } from "./types.js";

/**
Expand All @@ -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<MerkleProof> {
// 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 };
16 changes: 16 additions & 0 deletions src/notificationCenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
54 changes: 53 additions & 1 deletion src/trustlineChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* trustlines established.
*/

import { Horizon } from "@stellar/stellar-sdk";
import { Asset, Horizon } from "@stellar/stellar-sdk";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -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<Map<Asset, boolean>> {
const result = new Map<Asset, boolean>();

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;
}
48 changes: 48 additions & 0 deletions src/xdrDecoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading