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
101 changes: 99 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,12 @@ import {
StellarSplitError,
AdminOperationError,
PassphraseMismatchError,
InvoiceIntegrityError,
InvalidTransactionTypeError,
} from "./errors.js";
import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js";
import { buildFeeBump } from "./feeBumpBuilder.js";
import type { FeeBumpConfig } from "./feeBumpBuilder.js";
import { replayEvents } from "./events.js";
import { subscribeToInvoice as _subscribeToInvoice } from "./stream.js";
import { subscribeToInvoice as _subscribeToInvoiceSSE } from "./sse.js";
Expand Down Expand Up @@ -590,8 +595,6 @@ export function verifyCompletionProof(proof: CompletionProof): {
}
return { valid: true };
}
export class StellarSplitClient extends EventEmitter {

export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
private _mainServer!: SorobanRpc.Server;
private _standby: WarmStandby | null = null;
Expand Down Expand Up @@ -2008,7 +2011,18 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
donateOnFailure?: boolean;
waterfallPlan?: WaterfallPlan;
allowPartial?: boolean;
expectedContentHash?: string;
}): Promise<TxResult> {
// Verify invoice content hash if provided (integrity check)
if (params.expectedContentHash) {
const invoice = await this.getInvoice(params.invoiceId);
const valid = await verifyInvoiceHash(invoice, params.expectedContentHash);
if (!valid) {
const computed = await hashInvoice(invoice);
throw new InvoiceIntegrityError(params.invoiceId, params.expectedContentHash, computed);
}
}

if (!params.waterfallPlan) {
return this.pay({
payer: params.payer,
Expand Down Expand Up @@ -6334,6 +6348,89 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
this.emit("wallet:disconnected", { walletName });
}
}

// ---------------------------------------------------------------------------
// Invoice Hash Verification
// ---------------------------------------------------------------------------

/**
* Verify that an invoice's content hash matches the expected hash,
* detecting tampering between creation and payment.
*
* @param invoice - The invoice to verify.
* @param expectedHash - Previously computed content hash.
* @returns `true` when hashes match.
* @throws InvoiceIntegrityError when hashes diverge.
*/
async verifyInvoice(invoice: Invoice, expectedHash: string): Promise<boolean> {
const valid = await verifyInvoiceHash(invoice, expectedHash);
if (!valid) {
const computed = await hashInvoice(invoice);
throw new InvoiceIntegrityError(invoice.id, expectedHash, computed);
}
return true;
}

// ---------------------------------------------------------------------------
// Fee Bump Submission
// ---------------------------------------------------------------------------

/**
* Build and submit a fee bump transaction wrapping an inner transaction
* signed by a recipient who cannot pay their own fee.
*
* @param innerTxXdr - Base-64 XDR of the inner signed transaction.
* @param feeSource - Stellar address of the account paying the fee.
* @param baseFee - Base fee in stroops.
* @param config - Optional surge multiplier config.
* @returns The fee bump transaction hash.
*/
async submitWithFeeBump(
innerTxXdr: string,
feeSource: string,
baseFee?: string,
config?: FeeBumpConfig,
): Promise<TxResult> {
const innerTx = TransactionBuilder.fromXDR(
innerTxXdr,
this.config.networkPassphrase,
) as Transaction;

const effectiveBaseFee = baseFee ?? BASE_FEE;
const feeBumpTx = buildFeeBump(innerTx, feeSource, effectiveBaseFee, this.config.networkPassphrase, config);

const signedXdr = await (this._adapter
? this._adapter.signTransaction(feeBumpTx.toXDR(), this.config.networkPassphrase)
: signTransaction(feeBumpTx.toXDR(), this.config.networkPassphrase));

const sendResult = await this.server.sendTransaction(
TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase),
);

if (sendResult.status === "ERROR") {
throw new TransactionFailedError(
`Fee bump transaction failed: ${JSON.stringify(sendResult.errorResult)}`,
);
}

const txHash = sendResult.hash;
let getResult = await this.server.getTransaction(txHash);
let attempts = 0;
while (
getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND &&
attempts < 20
) {
await new Promise((r) => setTimeout(r, 1500));
getResult = await this.server.getTransaction(txHash);
attempts++;
}

if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
throw new TransactionNotConfirmedError(String(getResult.status));
}

return { txHash };
}
}

/** Coerce a native-decoded scalar (bigint | number | string) into a bigint, defaulting to 0n. */
Expand Down
170 changes: 170 additions & 0 deletions src/currencyNormalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Currency Normalizer — translates between on-chain integer representations
* and human-readable decimal values for every asset the SDK handles.
*
* Stellar assets carry asset-specific decimal precisions that differ from the
* seven-decimal XLM base unit. This module normalises raw stroop / sub-unit
* amounts before arithmetic to prevent silent rounding errors in invoice totals.
*/

import { Asset } from "@stellar/stellar-sdk";
import { PrecisionError } from "./errors.js";

/**
* Well-known asset precisions keyed by canonical asset identifier.
* Extends the built-in "native" (XLM) and SEP-41 token decimals.
*/
const ASSET_PRECISIONS: Record<string, number> = {
native: 7, // XLM = 7 decimal places
};

/**
* Register a custom asset's decimal precision for normalisation.
* Called automatically by SEP-41 token metadata fetchers; callers
* can also pre-seed known tokens to avoid a fetch.
*
* @param assetCode - Canonical asset identifier (contract address or "native").
* @param decimals - Number of decimal places (e.g., 7 for USDC on Stellar).
*/
export function registerAssetPrecision(assetCode: string, decimals: number): void {
ASSET_PRECISIONS[assetCode] = decimals;
}

/**
* Resolve the decimal precision for an asset.
* Returns 7 for XLM, stored precision for registered tokens, and throws
* for unknown assets.
*
* @param asset - Stellar SDK Asset instance or canonical identifier string.
* @returns The number of decimal places for this asset.
*/
export function getAssetPrecision(asset: Asset | string): number {
if (typeof asset === "string") {
const precision = ASSET_PRECISIONS[asset];
if (precision !== undefined) return precision;
throw new PrecisionError(
asset,
asset,
"Unknown asset precision; call registerAssetPrecision() first",
);
}

if (asset.isNative()) {
return 7;
}

const code = `${asset.getCode()}:${asset.getIssuer()}`;
const precision = ASSET_PRECISIONS[code] ?? ASSET_PRECISIONS[asset.getCode()];
if (precision !== undefined) return precision;

throw new PrecisionError(
asset.getCode(),
code,
"Unknown asset precision; call registerAssetPrecision() first",
);
}

/**
* Normalise a raw on-chain amount (string or bigint) to a display-formatted
* string with the correct number of decimal places.
*
* Example: normalizeAmount(10000000n, Asset.native()) => "1.0000000"
*
* @param raw - Raw on-chain amount in stroops / sub-units.
* @param asset - Stellar Asset instance or canonical identifier.
* @returns Decimal string with the asset-appropriate number of places.
*/
export function normalizeAmount(
raw: string | bigint,
asset: Asset | string,
): string {
const precision = getAssetPrecision(asset);
const rawValue = typeof raw === "string" ? BigInt(raw) : raw;

if (precision === 0) return rawValue.toString();

const divisor = 10n ** BigInt(precision);
const intPart = rawValue / divisor;
const fracPart = rawValue % divisor;

// Pad fractional part to exactly `precision` digits
const fracStr = fracPart
.toString()
.padStart(precision, "0")
.replace(/0+$/, "");

if (fracStr.length === 0) {
return `${intPart}.${"0".repeat(precision)}`;
}

return `${intPart}.${fracStr.padEnd(precision, "0")}`;
}

/**
* Convert a display-formatted decimal amount to the canonical on-chain
* integer (stroop / sub-unit) string accepted by @stellar/stellar-sdk operations.
*
* Example: toOnChainAmount("1.5", Asset.native()) => "15000000"
*
* @param display - Human-readable decimal amount.
* @param asset - Stellar Asset instance or canonical identifier.
* @returns Canonical integer string suitable for SDK operations.
* @throws PrecisionError when the conversion would lose sub-unit precision.
*/
export function toOnChainAmount(
display: string,
asset: Asset | string,
): string {
const precision = getAssetPrecision(asset);

// Parse the decimal string
const dotIndex = display.indexOf(".");
if (dotIndex === -1) {
// Whole number — multiply by 10^precision
return (BigInt(display) * (10n ** BigInt(precision))).toString();
}

const intPart = display.slice(0, dotIndex).replace(/^0+(?=\d)/, "") || "0";
let fracPart = display.slice(dotIndex + 1);

// Validate fractional part length does not exceed precision
if (fracPart.length > precision) {
throw new PrecisionError(
display,
typeof asset === "string" ? asset : asset.getCode(),
`Fractional part has ${fracPart.length} digits but precision is ${precision}`,
);
}

// Pad fractional part to precision
fracPart = fracPart.padEnd(precision, "0");

const result = BigInt(intPart) * (10n ** BigInt(precision)) + BigInt(fracPart);
return result.toString();
}

/**
* Round-trip test: convert display to on-chain and back, verifying the
* original display string is recovered (modulo trailing zeros).
*
* @param display - Human-readable decimal amount.
* @param asset - Stellar Asset instance or canonical identifier.
* @returns `true` if the round-trip preserves the value.
*/
export function verifyRoundTrip(
display: string,
asset: Asset | string,
): boolean {
try {
const onChain = toOnChainAmount(display, asset);
const back = normalizeAmount(onChain, asset);
// Normalise both to remove trailing zeros for comparison
const normalisedDisplay = display.includes(".")
? display.replace(/0+$/, "").replace(/\.$/, ".0")
: display + ".0";
const normalisedBack = back.replace(/0+$/, "").replace(/\.$/, ".0");
return normalisedDisplay === normalisedBack;
} catch {
return false;
}
}
78 changes: 78 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,3 +1459,81 @@ export class PassphraseMismatchError extends StellarSplitError {
export function isIPFSConfigError(err: unknown): err is IPFSConfigError {
return err instanceof IPFSConfigError;
}

// ---------------------------------------------------------------------------
// Invoice Hash Verifier errors
// ---------------------------------------------------------------------------

/** Thrown when invoice content hash verification fails (tampering detected). */
export class InvoiceIntegrityError extends StellarSplitError {
readonly invoiceId: string;
readonly expectedHash: string;
readonly computedHash: string;

constructor(invoiceId: string, expectedHash: string, computedHash: string) {
super(
`Invoice integrity check failed for ${invoiceId}: hash mismatch (expected ${expectedHash.slice(0, 8)}..., got ${computedHash.slice(0, 8)}...)`,
"INVOICE_INTEGRITY_ERROR",
{ invoiceId, expectedHash, computedHash },
);
this.name = "InvoiceIntegrityError";
this.invoiceId = invoiceId;
this.expectedHash = expectedHash;
this.computedHash = computedHash;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isInvoiceIntegrityError(err: unknown): err is InvoiceIntegrityError {
return err instanceof InvoiceIntegrityError;
}

// ---------------------------------------------------------------------------
// Fee Bump Builder errors
// ---------------------------------------------------------------------------

/** Thrown when a v0 transaction is passed to buildFeeBump (only v1 supported). */
export class InvalidTransactionTypeError extends StellarSplitError {
readonly txType: string;

constructor(txType: string) {
super(
`Invalid transaction type: expected v1 envelope, got ${txType}`,
"INVALID_TRANSACTION_TYPE",
{ txType },
);
this.name = "InvalidTransactionTypeError";
this.txType = txType;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isInvalidTransactionTypeError(err: unknown): err is InvalidTransactionTypeError {
return err instanceof InvalidTransactionTypeError;
}

// ---------------------------------------------------------------------------
// Currency Normalizer errors
// ---------------------------------------------------------------------------

/** Thrown when amount conversion would lose sub-unit precision. */
export class PrecisionError extends StellarSplitError {
readonly amount: string;
readonly asset: string;

constructor(amount: string, asset: string, details?: string) {
super(
`Precision loss converting ${amount} for asset ${asset}${details ? `: ${details}` : ""}`,
"PRECISION_ERROR",
{ amount, asset, details },
);
this.name = "PrecisionError";
this.amount = amount;
this.asset = asset;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isPrecisionError(err: unknown): err is PrecisionError {
return err instanceof PrecisionError;
}
Loading