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
92 changes: 73 additions & 19 deletions src/services/stellar/payment-distributor-contract.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Contract, Address, nativeToScVal, xdr, SorobanRpc, Keypair, TransactionBuilder, BASE_FEE } from "stellar-sdk";
import {
Contract,
Address,
nativeToScVal,
xdr,
SorobanRpc,
Keypair,
TransactionBuilder,
BASE_FEE,
} from "stellar-sdk";
import type { AppLogger } from "../../observability/logger";
import { logger as globalLogger } from "../../observability/logger";

Expand Down Expand Up @@ -31,7 +40,10 @@ export interface DistributePayoutsInput {
feeBps: number;
}

export interface DistributePayoutsResult { transactionHash: string; ledger: number | null; }
export interface DistributePayoutsResult {
transactionHash: string;
ledger: number | null;
}

const MAX_FEE_BPS = 10_000;

Expand Down Expand Up @@ -59,7 +71,7 @@ export class PaymentDistributorContractService {

constructor(
dependenciesOrContractId: string | PaymentDistributorContractServiceDependencies,
logger?: AppLogger,
logger?: AppLogger
) {
if (typeof dependenciesOrContractId === "string") {
if (!dependenciesOrContractId) {
Expand Down Expand Up @@ -108,7 +120,7 @@ export class PaymentDistributorContractService {
invoiceId: string,
recipients: PayoutRecipient[],
platformFeeAccount: string,
feeBps: number,
feeBps: number
): xdr.Operation {
if (recipients.length === 0) {
throw new Error("At least one payout recipient is required.");
Expand All @@ -118,7 +130,7 @@ export class PaymentDistributorContractService {
}

const recipientAddressesScVal = xdr.ScVal.scvVec(
recipients.map((recipient) => new Address(recipient.address).toScVal()),
recipients.map((recipient) => new Address(recipient.address).toScVal())
);

const recipientAmountsScVal = xdr.ScVal.scvVec(
Expand All @@ -128,7 +140,7 @@ export class PaymentDistributorContractService {
? recipient.amountStroops
: BigInt(recipient.amountStroops);
return nativeToScVal(amountBigInt, { type: "i128" });
}),
})
);

return this.contract.call(
Expand All @@ -137,7 +149,7 @@ export class PaymentDistributorContractService {
recipientAddressesScVal,
recipientAmountsScVal,
new Address(platformFeeAccount).toScVal(),
nativeToScVal(feeBps, { type: "u32" }),
nativeToScVal(feeBps, { type: "u32" })
);
}

Expand All @@ -148,7 +160,10 @@ export class PaymentDistributorContractService {
if (this.verifyDistributorWiring && !(await this.verifyDistributorWiring())) {
throw new Error("Payment distributor is not initialized on the invoice escrow contract.");
}
const recipientTotal = input.recipients.reduce((sum, recipient) => sum + BigInt(recipient.amountStroops), 0n);
const recipientTotal = input.recipients.reduce(
(sum, recipient) => sum + BigInt(recipient.amountStroops),
0n
);
const fee = (input.totalAmountStroops * BigInt(input.feeBps)) / 10_000n;
if (recipientTotal + fee > input.totalAmountStroops) {
throw new Error("Payout recipients and protocol fee exceed the settlement total.");
Expand All @@ -159,21 +174,60 @@ export class PaymentDistributorContractService {
const transaction = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.networkPassphrase,
}).addOperation(this.buildDistributePayoutsTx(input.invoiceId, input.recipients, input.feeRecipient, input.feeBps)).setTimeout(30).build();
})
.addOperation(
this.buildDistributePayoutsTx(
input.invoiceId,
input.recipients,
input.feeRecipient,
input.feeBps
)
)
.setTimeout(30)
.build();
const prepared = await this.rpcServer.prepareTransaction(transaction);
prepared.sign(signer);
const submitted = await this.rpcServer.sendTransaction(prepared);
if (submitted.status === "ERROR") throw new Error("Payment distribution transaction was rejected by Soroban RPC.");

for (let attempt = 0; attempt < this.confirmationAttempts; attempt++) {
const result = await this.rpcServer.getTransaction(submitted.hash);
if (result.status === "SUCCESS") {
this.logger.info("Payment distribution confirmed on-chain.", { invoice_id: input.invoiceId, transaction_hash: submitted.hash });
return { transactionHash: submitted.hash, ledger: "ledger" in result ? Number(result.ledger) : null };
// Submit and map Soroban RPC errors to ServiceError with retryability
try {
const submitted = await this.rpcServer.sendTransaction(prepared);
if (submitted.status === "ERROR") {
// Map to a ServiceError (non-retryable transaction rejection)
throw require("../stellar/soroban-error-mapper").mapSorobanError(submitted, {
contractId: this.contractId,
}).error;
}

for (let attempt = 0; attempt < this.confirmationAttempts; attempt++) {
const result = await this.rpcServer.getTransaction(submitted.hash);
if (result.status === "SUCCESS") {
this.logger.info("Payment distribution confirmed on-chain.", {
invoice_id: input.invoiceId,
transaction_hash: submitted.hash,
});
return {
transactionHash: submitted.hash,
ledger: "ledger" in result ? Number(result.ledger) : null,
};
}
if (result.status === "FAILED") {
throw require("../stellar/soroban-error-mapper").mapSorobanError(
{ status: "FAILED" },
{ contractId: this.contractId }
).error;
}
await new Promise((resolve) => setTimeout(resolve, this.confirmationPollMs));
}
if (result.status === "FAILED") throw new Error("Payment distribution transaction reverted on-chain.");
await new Promise((resolve) => setTimeout(resolve, this.confirmationPollMs));
throw new Error("Timed out waiting for payment distribution confirmation.");
} catch (err) {
// If it's already a ServiceError, rethrow; otherwise map and throw a sanitized ServiceError
if (err instanceof Error && (err as any).name === "ServiceError") throw err;
const mapper = require("../stellar/soroban-error-mapper");
const mapped = mapper.mapSorobanError(err, {
contractId: this.contractId,
invoiceId: input.invoiceId,
});
throw mapped.error;
}
throw new Error("Timed out waiting for payment distribution confirmation.");
}
}
126 changes: 126 additions & 0 deletions src/services/stellar/soroban-error-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ServiceError } from "../../utils/service-error";

export interface MappedSorobanError {
error: ServiceError;
retryable: boolean;
cause?: string;
}

/**
* Map common Soroban RPC / contract errors into stable ServiceError categories.
*
* This intentionally returns a sanitized ServiceError and a `retryable` flag.
* Avoid embedding raw provider payloads or secrets in the returned error.
*/
export function mapSorobanError(
input: unknown,
context: { contractId?: string; invoiceId?: string } = {}
): MappedSorobanError {
// Normalize message
const message =
input instanceof Error
? input.message
: typeof input === "string"
? input
: JSON.stringify(input ?? {});

const lower = (message ?? "").toLowerCase();

// Timeouts / network blips
if (/timeout|timed out|etimedout/.test(lower)) {
return {
error: new ServiceError("soroban_timeout", "Soroban RPC timed out.", 503, {
contractId: context.contractId,
invoiceId: context.invoiceId,
}),
retryable: true,
cause: "timeout",
};
}

// Rate limiting
if (/429|rate limit|too many requests/.test(lower)) {
return {
error: new ServiceError("soroban_rate_limited", "Soroban RPC rate limited.", 503, {
contractId: context.contractId,
}),
retryable: true,
cause: "rate_limited",
};
}

// Simulation failures (contract-level reverts, validation failures)
if (/simulate|simulation failed|simulation error|revert|reverted|contract failed/.test(lower)) {
return {
error: new ServiceError(
"soroban_simulation_failed",
"Soroban transaction simulation failed.",
422,
{ contractId: context.contractId }
),
retryable: false,
cause: "simulation_failed",
};
}

// Authorization failures
if (/authorization|auth failed|unauthorized|not authorized|forbidden/.test(lower)) {
return {
error: new ServiceError("soroban_unauthorized", "Soroban authorization failed.", 403, {
contractId: context.contractId,
}),
retryable: false,
cause: "unauthorized",
};
}

// Capacity / resource exhaustion
if (
/capacity|out of memory|insufficient resources|gas|resource limit|limit exceeded/.test(lower)
) {
return {
error: new ServiceError("soroban_capacity_exceeded", "Soroban node capacity exceeded.", 503, {
contractId: context.contractId,
}),
retryable: true,
cause: "capacity_exceeded",
};
}

// Transaction-level rejection reported by sendTransaction result
if (typeof input === "object" && input !== null && "status" in (input as any)) {
const st = String((input as any).status).toUpperCase();
if (st === "ERROR" || st === "FAILED") {
return {
error: new ServiceError("soroban_tx_rejected", "Soroban transaction was rejected.", 422, {
contractId: context.contractId,
}),
retryable: false,
cause: "tx_rejected",
};
}
if (st === "TRY_AGAIN_LATER") {
return {
error: new ServiceError(
"soroban_try_again_later",
"Soroban RPC asked to try again later.",
503,
{ contractId: context.contractId }
),
retryable: true,
cause: "try_again_later",
};
}
}

// Fallback: generic RPC error, mark retryable (network/backpressure)
return {
error: new ServiceError("soroban_rpc_error", "Soroban RPC error.", 502, {
contractId: context.contractId,
}),
retryable: true,
cause: "rpc_error",
};
}

export default mapSorobanError;
Loading