diff --git a/.env.example b/.env.example index 385720b2..231a6f2a 100644 --- a/.env.example +++ b/.env.example @@ -233,18 +233,32 @@ SOROBAN_RPC_ENABLED=false SOROBAN_RPC_URL=https://soroban-testnet.stellar.org SOROBAN_RPC_TIMEOUT=2000 -# Billing RPC: contract ID is required in production. In development/test, -# omit it if billing deduction routes are unused or inject a fake client. +# Billing RPC: SorobanVaultBillingClient builds, signs, submits and confirms +# real vault deduct(caller, amount, request_id) transactions with stellar-sdk. +# Required keys: SOROBAN_BILLING_BACKEND_SECRET_KEY (the authorized caller's +# S... secret; the service logs a config warning and disables billing without +# it) and a contract id (SOROBAN_BILLING_CONTRACT_ID, required in production). +# Contract ID is required in production. In development/test, omit it if +# billing deduction routes are unused or inject a fake client. # Billing RPC URL falls back to SOROBAN_RPC_URL, then http://localhost:8000. +# Deployed testnet vault (Callora-Contracts docs/DEPLOYMENTS.md): +# vault = CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ +# caller = GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ (authorized_caller, max_deduct = 1000000000) +# RPC = https://soroban-testnet.stellar.org +# passphrase = Test SDF Network ; September 2015 SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org -SOROBAN_BILLING_CONTRACT_ID=your-vault-contract-id +SOROBAN_BILLING_CONTRACT_ID=CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -SOROBAN_BILLING_SOURCE_ACCOUNT=your-backend-source-account -# Reserved for a future signing integration; current billing RPC client does not read it. +# Must equal the public key derived from SOROBAN_BILLING_BACKEND_SECRET_KEY +# (validated at startup) and be the vault's authorized_caller. +SOROBAN_BILLING_SOURCE_ACCOUNT=GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ +# Required: signs the vault deduct. When unset the billing service is +# disabled (config warning, no partial client) instead of failing at runtime. SOROBAN_BILLING_BACKEND_SECRET_KEY=your-backend-secret-key SOROBAN_BILLING_BALANCE_FN=balance SOROBAN_BILLING_DEDUCT_FN=deduct -# Positive integer timeout in milliseconds. +# Positive integer timeout in milliseconds. Also the polling budget for one +# deduct call: polling finishes within SOROBAN_BILLING_RPC_TIMEOUT_MS x attempts. SOROBAN_BILLING_RPC_TIMEOUT_MS=5000 # Stellar secret key used to submit fee-bumped transactions. Default: unset; fee bumping fails closed when missing. FEE_BUMPER_SECRET_KEY= diff --git a/docs/fee-abstraction.md b/docs/fee-abstraction.md index b6f8e6b3..f7271734 100644 --- a/docs/fee-abstraction.md +++ b/docs/fee-abstraction.md @@ -158,3 +158,38 @@ This event can trigger downstream webhook deliveries if the developer has subscr | `FEE_BUMPER_SECRET_KEY` | **Yes** | Stellar secret key (`S...`) for the platform fee account | | `STELLAR_BASE_FEE` | No (default `100`) | Base fee per operation in stroops | | `STELLAR_NETWORK` | No (default `testnet`) | Active network: `testnet` or `mainnet` | + +--- + +## Soroban billing (vault deduct) configuration + +Fee abstraction covers classic Stellar fee bumps; the billing deduction itself +runs on Soroban through `SorobanVaultBillingClient`, which builds, signs, +submits and confirms real `vault.deduct(caller, amount, request_id)` +transactions. These keys are required for that flow: + +| Variable | Required | Description | +|---|---|---| +| `SOROBAN_BILLING_BACKEND_SECRET_KEY` | **Yes** | Stellar secret (`S...`) of the vault's `authorized_caller`; signs every deduct. When unset, the billing service logs a config warning and stays disabled (no partial client). | +| `SOROBAN_BILLING_SOURCE_ACCOUNT` | **Yes** (validated) | Public key derived from the secret above; must also equal the vault's `authorized_caller`. | +| `SOROBAN_BILLING_CONTRACT_ID` | **Yes** (required in production) | Default vault contract id; per-user vaults from `VaultRepository` take precedence. | +| `SOROBAN_BILLING_RPC_URL` | Recommended | Soroban RPC endpoint (falls back to `SOROBAN_RPC_URL`, then `http://localhost:8000` outside production). | +| `SOROBAN_BILLING_NETWORK_PASSPHRASE` | Recommended | Must match the active `STELLAR_NETWORK`. | +| `SOROBAN_BILLING_RPC_TIMEOUT_MS` | No (default `5000`) | Total RPC + polling budget for one deduct call; polling always finishes within `SOROBAN_BILLING_RPC_TIMEOUT_MS × attempts`. | +| `SOROBAN_BILLING_BALANCE_FN` / `SOROBAN_BILLING_DEDUCT_FN` | No (defaults `balance` / `deduct`) | Vault entrypoints. | + +Deployed testnet values (from `Callora-Contracts` `docs/DEPLOYMENTS.md`): + +```bash +SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org +SOROBAN_BILLING_CONTRACT_ID=CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ +SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +SOROBAN_BILLING_SOURCE_ACCOUNT=GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ +SOROBAN_BILLING_BACKEND_SECRET_KEY= +``` + +The testnet vault is initialised with `authorized_caller = +GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ` and +`max_deduct = 1000000000`. The on-chain `request_id` is the row-scoped +`usage_events.onchain_request_id` (Postgres sequence, never 0) — see +`docs/sdk/billing-deduct.md` for the idempotency contract. diff --git a/docs/schema.md b/docs/schema.md index b1582599..412b26af 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -140,6 +140,7 @@ Tracks every proxied API invocation and Soroban billing deduction. Hash-partitio | `developer_id` | VARCHAR(255) | NOT NULL DEFAULT '' | API owner and hash partition key | | `amount_usdc` | NUMERIC(30, 0) | NOT NULL, `CHECK (amount_usdc > 0)` | **Integer contract units** ("stroops", 7 decimals). 1 USDC = 10,000,000 contract units. | | `request_id` | VARCHAR(255) | Part of UNIQUE `(request_id, developer_id)` | Idempotency correlation ID | +| `onchain_request_id` | BIGINT | NULLABLE, nextval(`usage_events_onchain_request_id_seq`) at insert | u64 sent as the vault `deduct` `request_id`. Allocated from a Postgres sequence when the Phase 1 row is inserted and reused for every retry of that row, so the vault's replay protection makes double charging impossible. Never 0 (the vault's "no idempotency" sentinel). See `docs/sdk/billing-deduct.md`. | | `stellar_tx_hash` | VARCHAR(64) | NULLABLE | On-chain Soroban deduction transaction hash | | `created_at` | TIMESTAMP | NOT NULL DEFAULT NOW() | Creation timestamp | diff --git a/docs/soroban-simulation-diagnostics.md b/docs/soroban-simulation-diagnostics.md index 91e64e98..fbf16bf4 100644 --- a/docs/soroban-simulation-diagnostics.md +++ b/docs/soroban-simulation-diagnostics.md @@ -25,3 +25,41 @@ API responses expose only a redacted summary: ``` Full account identifiers, contract identifiers, balances, XDR, keys, signatures, and hashes are redacted before diagnostics are returned to callers. Server-side warning logs retain the structured internal diagnostics for support debugging. + +## Vault contract error codes + +The billing deduct client (`SorobanVaultBillingClient`) decodes the numeric +discriminant from `Error(Contract, #N)` strings returned by simulation or by a +failed transaction, stores it as `simulationDetails.errorCode`, and maps it to +a stable category that drives the HTTP status: + +| Code | `VaultError` | Category | Billing outcome | Typical HTTP response | +|---|---|---|---|---| +| 5 | `InsufficientBalance` | `INSUFFICIENT_BALANCE` | Row voided (definitive). Detected earlier by the balance pre-check → 402 without contract details. | 502 `SIMULATION_FAILED` with `errorCode: 5`, or 402 `INSUFFICIENT_BALANCE` | +| 3 | `Unauthorized` | `CONTRACT_ERROR` | Row voided (definitive) — the backend key is not the vault's `authorized_caller` | 502 `SIMULATION_FAILED` with `errorCode: 3` | +| 4 | `Paused` | `CONTRACT_ERROR` | Row voided (definitive) — the vault is paused | 502 `SIMULATION_FAILED` with `errorCode: 4` | +| 7 | `ExceedsMaxDeduct` | `CONTRACT_ERROR` | Row voided (definitive) — amount above the vault's `max_deduct` cap | 502 `SIMULATION_FAILED` with `errorCode: 7` | +| 29 | `DuplicateRequestId` | resolved or `TIMEOUT` | The request id was already processed: the client returns the original transaction hash when it can confirm it, otherwise reports an unknown outcome so the row stays **pending** (never voided, never double charged) | 200 with the original `stellarTxHash`, or 409 `RECONCILIATION_REQUIRED` | + +Failures with no decodable code keep the message-based classification +(`simulation failed`/`contract` → `CONTRACT_ERROR`, transport errors → +`NETWORK_ERROR`, deadline exhausted → `TIMEOUT`). `NETWORK_ERROR` and +`TIMEOUT` are transient for `BillingService`: the deduction is retried with +the same `onchain_request_id`, and when the attempts run out the row stays +pending (409 `RECONCILIATION_REQUIRED`) instead of being voided. + +Because every coded contract failure carries `simulationDetails`, the billing +route renders it through the canonical redacted envelope — the numeric code +survives redaction while addresses, XDR, keys and hashes do not: + +```json +{ + "code": "SIMULATION_FAILED", + "simulationDetails": { + "errorCode": 4, + "errorMessage": "io.callora: Error(Contract, #4)", + "eventCount": 0, + "footprintPresent": false + } +} +``` diff --git a/migrations/0033_usage_events_onchain_request_id.down.sql b/migrations/0033_usage_events_onchain_request_id.down.sql new file mode 100644 index 00000000..157dd907 --- /dev/null +++ b/migrations/0033_usage_events_onchain_request_id.down.sql @@ -0,0 +1,7 @@ +-- dialect: postgres +-- Rollback: drops the on-chain request id column and its sequence. + +ALTER TABLE usage_events + DROP COLUMN IF EXISTS onchain_request_id; + +DROP SEQUENCE IF EXISTS usage_events_onchain_request_id_seq; diff --git a/migrations/0033_usage_events_onchain_request_id.sql b/migrations/0033_usage_events_onchain_request_id.sql new file mode 100644 index 00000000..5646ba2c --- /dev/null +++ b/migrations/0033_usage_events_onchain_request_id.sql @@ -0,0 +1,19 @@ +-- dialect: postgres +-- Deterministic on-chain request ids for Soroban vault deductions. +-- +-- billing.ts allocates usage_events.onchain_request_id from this sequence in +-- the same transaction that inserts the Phase 1 row, and re-sends that same +-- u64 on every retry of the row. The vault contract stores the id for replay +-- protection (VaultError::DuplicateRequestId), so retries can never +-- double charge. The sequence starts at 1: 0 is the vault's documented +-- "no idempotency" sentinel and is never sent. +-- +-- The column is added WITHOUT a default on purpose: a volatile default +-- (nextval) would rewrite every partition of the live usage_events table. +-- Existing/legacy rows keep NULL and fall back to an id derived from their +-- idempotency key until re-armed. + +CREATE SEQUENCE IF NOT EXISTS usage_events_onchain_request_id_seq START 1; + +ALTER TABLE usage_events + ADD COLUMN IF NOT EXISTS onchain_request_id BIGINT; diff --git a/src/app.ts b/src/app.ts index 224348fa..aa3226d5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -67,7 +67,6 @@ import { interface AppDependencies extends SorobanBillingDependencies { usageEventsRepository?: UsageEventsRepository; healthCheckConfig?: HealthCheckConfig; - vaultRepository?: VaultRepository; vaultReader?: VaultStateReader; apiRepository?: ApiRepository; developerRepository?: DeveloperRepository; @@ -97,11 +96,16 @@ export const createApp = (dependencies?: Partial) => { }); // Set database pool in locals for billing routes app.locals.dbPool = pool; - app.locals.billingService = createSorobanBillingService(pool, dependencies); const usageEventsRepository = dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository(); - const vaultRepository = + // Resolve the vault repository first: the billing service uses it to map a + // user to their vault contract id when building vault deduct calls. + const vaultRepository: VaultRepository = dependencies?.vaultRepository ?? new InMemoryVaultRepository(); + app.locals.billingService = createSorobanBillingService(pool, { + ...dependencies, + vaultRepository, + }); const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId; const persistApi = dependencies?.createApiWithEndpoints ?? createApi; const { reportExporterService, scheduledExportsService } = createAppExports(usageEventsRepository); diff --git a/src/services/billing.ts b/src/services/billing.ts index daf57546..7215f7f7 100644 --- a/src/services/billing.ts +++ b/src/services/billing.ts @@ -45,6 +45,13 @@ export { parseUsdcToContractUnits, formatContractUnitsToUsdc }; const DEFAULT_RETRY_DELAYS_MS = [150, 500, 1_000]; +/** + * Postgres sequence backing `usage_events.onchain_request_id` (migration + * 0033). Values start at 1 and are never 0 — 0 is the vault contract's + * documented "no idempotency" sentinel and must never be sent on-chain. + */ +const ONCHAIN_REQUEST_ID_SQL = "nextval('usage_events_onchain_request_id_seq')"; + /** * Per-user FIFO concurrency gate for billing deductions. * @@ -112,10 +119,21 @@ export interface SorobanDeductResult { export interface SorobanClient { getBalance(userId: string): Promise; + /** + * Charge `amount` (contract units) against the user's vault. + * + * `onchainRequestId` is the row-scoped `usage_events.onchain_request_id` + * (a u64 allocated from a Postgres sequence when the Phase 1 row was + * inserted). It is passed on every retry of the same row so the vault's + * replay protection guarantees the deduction can only ever land once. + * Clients derive a deterministic non-zero u64 from `idempotencyKey` when it + * is omitted; zero (the vault's "no idempotency" sentinel) is never sent. + */ deductBalance( userId: string, amount: string, idempotencyKey?: string, + onchainRequestId?: string, ): Promise; deductBalanceBatch?( userId: string, @@ -277,6 +295,11 @@ interface Phase1Result { usageEventId?: string; stellarTxHash?: string; deductionStatus?: DeductionStatus; + /** + * u64 allocated from the Postgres sequence for this row. Reused for every + * retry of the same row so the vault's replay protection holds. + */ + onchainRequestId?: string; } interface ExistingUsageEventRow { @@ -289,6 +312,7 @@ interface ExistingUsageEventRow { interface BulkInsertedUsageEvent { requestId: string; usageEventId: string; + onchainRequestId?: string; } interface BulkPhase1Result { @@ -325,15 +349,17 @@ async function runPhase1( if (status === "voided") { // Re-arm the row inside the Phase 1 lock! - await client.query( + const rearmResult = await client.query<{ onchain_request_id: string | null }>( `UPDATE usage_events SET deduction_status = 'pending', stellar_tx_hash = NULL, resolved_at = NULL, resolution_reason = NULL, amount_usdc = $1, + onchain_request_id = COALESCE(onchain_request_id, ${ONCHAIN_REQUEST_ID_SQL}), created_at = NOW() - WHERE id = $2`, + WHERE id = $2 + RETURNING onchain_request_id`, [request.amountUsdc, row.id], ); await client.query("COMMIT"); @@ -341,6 +367,8 @@ async function runPhase1( alreadyExists: false, usageEventId: row.id.toString(), deductionStatus: "pending", + onchainRequestId: + rearmResult.rows[0]?.onchain_request_id?.toString() ?? undefined, }; } @@ -362,11 +390,17 @@ async function runPhase1( // INSERT the event with stellar_tx_hash = NULL and deduction_status = 'pending'. // Committed immediately so the record survives even if Phase 2 fails. - const insertResult = await client.query<{ id: string }>( + // onchain_request_id is allocated from the Postgres sequence in the same + // statement, so the row is born with the u64 that every retry of this row + // will re-send to the vault. + const insertResult = await client.query<{ + id: string; + onchain_request_id: string | number; + }>( `INSERT INTO usage_events - (user_id, api_id, endpoint_id, api_key_id, amount_usdc, request_id, created_at, deduction_status) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), 'pending') - RETURNING id`, + (user_id, api_id, endpoint_id, api_key_id, amount_usdc, request_id, onchain_request_id, created_at, deduction_status) + VALUES ($1, $2, $3, $4, $5, $6, ${ONCHAIN_REQUEST_ID_SQL}, NOW(), 'pending') + RETURNING id, onchain_request_id`, [ request.userId, request.apiId, @@ -383,6 +417,7 @@ async function runPhase1( alreadyExists: false, usageEventId: insertResult.rows[0].id.toString(), deductionStatus: "pending", + onchainRequestId: insertResult.rows[0]?.onchain_request_id?.toString(), }; } @@ -478,15 +513,17 @@ async function runPhase1Bulk( (existingRow.stellar_tx_hash ? "confirmed" : "pending"); if (status === "voided") { - await client.query( + const rearmResult = await client.query<{ onchain_request_id: string | null }>( `UPDATE usage_events SET deduction_status = 'pending', stellar_tx_hash = NULL, resolved_at = NULL, resolution_reason = NULL, amount_usdc = $1, + onchain_request_id = COALESCE(onchain_request_id, ${ONCHAIN_REQUEST_ID_SQL}), created_at = NOW() - WHERE id = $2`, + WHERE id = $2 + RETURNING onchain_request_id`, [request.amountUsdc, existingRow.id], ); existingRow.stellar_tx_hash = null; @@ -494,6 +531,8 @@ async function runPhase1Bulk( inserted.push({ requestId: request.requestId, usageEventId: existingRow.id, + onchainRequestId: + rearmResult.rows[0]?.onchain_request_id?.toString() ?? undefined, }); } continue; @@ -504,11 +543,14 @@ async function runPhase1Bulk( throw new Error(`Missing amount for request ${request.requestId}`); } - const insertResult = await client.query<{ id: string }>( + const insertResult = await client.query<{ + id: string; + onchain_request_id: string | number; + }>( `INSERT INTO usage_events - (user_id, api_id, endpoint_id, api_key_id, amount_usdc, request_id, created_at, deduction_status) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), 'pending') - RETURNING id`, + (user_id, api_id, endpoint_id, api_key_id, amount_usdc, request_id, onchain_request_id, created_at, deduction_status) + VALUES ($1, $2, $3, $4, $5, $6, ${ONCHAIN_REQUEST_ID_SQL}, NOW(), 'pending') + RETURNING id, onchain_request_id`, [ request.userId, request.apiId, @@ -530,6 +572,7 @@ async function runPhase1Bulk( inserted.push({ requestId: request.requestId, usageEventId: insertedRow.id, + onchainRequestId: insertResult.rows[0]?.onchain_request_id?.toString(), }); } @@ -748,6 +791,7 @@ export class BillingService { request.userId, amountInContractUnits.toString(), request.idempotencyKey ?? request.requestId, + phase1.onchainRequestId, ); } catch (error) { const category = getSorobanErrorCategory(error); @@ -997,6 +1041,9 @@ export class BillingService { })), totalInsertedAmount.toString(), bulkKey, + // The aggregate deduction reuses the first inserted row's u64 so + // retries of the batch cannot double charge. + phase1.inserted[0]?.onchainRequestId, ); } catch (error) { const category = getSorobanErrorCategory(error); @@ -1138,6 +1185,7 @@ export class BillingService { userId: string, amount: string, idempotencyKey?: string, + onchainRequestId?: string, ): Promise { let lastError: unknown; @@ -1147,6 +1195,7 @@ export class BillingService { userId, amount, idempotencyKey, + onchainRequestId, ); } catch (error) { lastError = error; @@ -1181,9 +1230,15 @@ export class BillingService { items: Array<{ amount: string; requestId: string }>, aggregateAmount: string, idempotencyKey: string, + onchainRequestId?: string, ): Promise { if (!this.sorobanClient.deductBalanceBatch) { - return this.executeDeductWithRetry(userId, aggregateAmount, idempotencyKey); + return this.executeDeductWithRetry( + userId, + aggregateAmount, + idempotencyKey, + onchainRequestId, + ); } let lastError: unknown; diff --git a/src/services/createSorobanBillingService.ts b/src/services/createSorobanBillingService.ts index c5577852..b06059c5 100644 --- a/src/services/createSorobanBillingService.ts +++ b/src/services/createSorobanBillingService.ts @@ -1,13 +1,21 @@ import type { Pool } from "pg"; import { env } from "../config/env.js"; import { config } from "../config/index.js"; +import { logger } from "../logger.js"; +import type { VaultRepository } from "../repositories/vaultRepository.js"; import { BillingService, type SorobanClient } from "./billing.js"; -import { createSorobanRpcBillingClient } from "./sorobanBilling.js"; +import { SorobanVaultBillingClient } from "./sorobanVaultBillingClient.js"; export interface SorobanBillingDependencies { billingSorobanClient?: SorobanClient; createBillingSorobanClient?: () => SorobanClient; billingService?: BillingService; + /** + * Vault lookup used by the default client to resolve each user's vault + * contract id (falling back to SOROBAN_BILLING_CONTRACT_ID when the user + * has no registered vault). + */ + vaultRepository?: VaultRepository; } const DEFAULT_DEV_RPC_URL = "http://localhost:8000"; @@ -56,13 +64,26 @@ export function createSorobanBillingService( const contractId = resolveBillingContractId(); if (!contractId) return undefined; + // The vault deduct is signed by the backend's authorized caller, so no + // client can be constructed without the signing key. Missing key → no + // (partial) client and a config warning, not a runtime failure later. + const secretKey = env.SOROBAN_BILLING_BACKEND_SECRET_KEY; + if (!secretKey) { + logger.warn( + "[createSorobanBillingService] SOROBAN_BILLING_BACKEND_SECRET_KEY is not configured — Soroban billing is disabled until the backend signing key is set", + ); + return undefined; + } + processBillingService ??= new BillingService( pool, - createSorobanRpcBillingClient({ + new SorobanVaultBillingClient({ rpcUrl: resolveBillingRpcUrl(), contractId, - sourceAccount: env.SOROBAN_BILLING_SOURCE_ACCOUNT, + secretKey, networkPassphrase: resolveBillingNetworkPassphrase(), + network: config.stellar.network, + vaultRepository: dependencies?.vaultRepository, requestTimeoutMs: env.SOROBAN_BILLING_RPC_TIMEOUT_MS, balanceFunctionName: env.SOROBAN_BILLING_BALANCE_FN, deductFunctionName: env.SOROBAN_BILLING_DEDUCT_FN, diff --git a/src/services/sorobanBilling.ts b/src/services/sorobanBilling.ts index 581b35a6..9b21c96a 100644 --- a/src/services/sorobanBilling.ts +++ b/src/services/sorobanBilling.ts @@ -1,54 +1,20 @@ -import { - extractSimulationDetails, - type SimulationDetails, -} from '../lib/simulationDiagnostics.js'; -import { withSorobanLatencyWrapper } from '../../tests/chaos/sorobanLatency.js'; -import { env } from '../config/env.js'; -import { getOrCreateRequestId } from '../utils/asyncContext.js'; +import type { SimulationDetails } from '../lib/simulationDiagnostics.js'; -export interface SorobanBillingInvocationArg { - type: 'string' | 'i128'; - value: string; -} - -export interface SorobanBillingInvocation { - contractId: string; - function: string; - args: SorobanBillingInvocationArg[]; -} - -export interface SorobanBillingRpcRequest { - jsonrpc: '2.0'; - id: string; - method: 'simulateTransaction'; - params: { - invocation: SorobanBillingInvocation; - sourceAccount?: string; - networkPassphrase?: string; - }; -} - -export interface SorobanBillingClientOptions { - rpcUrl: string; - contractId: string; - sourceAccount?: string; - networkPassphrase?: string; - requestTimeoutMs?: number; - fetchImpl?: typeof fetch; - requestIdFactory?: () => string; - balanceFunctionName?: string; - deductFunctionName?: string; -} - -export interface SorobanBalanceResponse { - balance: string; -} - -export interface SorobanDeductResponse { - txHash: string; -} - -const DEFAULT_TIMEOUT_MS = 5_000; +/** + * Shared Soroban billing error types. + * + * The simulate-only JSON-RPC client that used to live here posted a made-up + * `simulateTransaction` payload the real Soroban RPC does not accept, never + * submitted anything, and invented a transaction hash from a simulation + * response. It was removed in favour of `SorobanVaultBillingClient` + * (src/services/sorobanVaultBillingClient.ts), which builds, signs, submits + * and confirms real `vault.deduct(caller, amount, request_id)` transactions + * with `@stellar/stellar-sdk`. + * + * `SorobanRpcError` and its stable categories are shared by the new client, + * the billing routes and `BillingService`, so they stay here to avoid churn in + * existing imports. + */ /** * Stable error categories for Soroban RPC failures. @@ -80,7 +46,12 @@ export class SorobanRpcError extends Error { } } -function classifyError(message: string): SorobanRpcErrorCategory { +/** + * Best-effort category guess from a plain error message. Used when a failure + * did not come back as a typed `SorobanRpcError` (transport failures, raw + * RPC/assemble errors). + */ +export function classifySorobanRpcError(message: string): SorobanRpcErrorCategory { const lower = message.toLowerCase(); if (lower.includes('insufficient balance') || lower.includes('insufficient funds')) { return 'INSUFFICIENT_BALANCE'; @@ -94,7 +65,11 @@ function classifyError(message: string): SorobanRpcErrorCategory { return 'NETWORK_ERROR'; } -function extractErrorMessage(error: unknown, depth = 0): string | undefined { +/** + * Pull a human-readable message out of an unknown thrown value without + * dumping the whole object (which may carry XDR, keys or addresses). + */ +export function extractErrorMessage(error: unknown, depth = 0): string | undefined { if (depth > 4 || error === null || error === undefined) { return undefined; } @@ -135,236 +110,9 @@ function extractErrorMessage(error: unknown, depth = 0): string | undefined { return undefined; } -function normalizeSorobanBillingError( +export function normalizeSorobanBillingError( error: unknown, fallback = 'Unknown Soroban error' ): string { return extractErrorMessage(error)?.replace(/\s+/g, ' ').trim() ?? fallback; } - -export function buildSorobanBalanceInvocation( - contractId: string, - userId: string, - functionName = 'balance' -): SorobanBillingInvocation { - return { - contractId, - function: functionName, - args: [{ type: 'string', value: userId }], - }; -} - -export function buildSorobanDeductInvocation( - contractId: string, - userId: string, - amount: string, - idempotencyKey?: string, - functionName = 'deduct' -): SorobanBillingInvocation { - const args: SorobanBillingInvocationArg[] = [ - { type: 'string', value: userId }, - { type: 'i128', value: amount }, - ]; - - if (idempotencyKey) { - args.push({ type: 'string', value: idempotencyKey }); - } - - return { - contractId, - function: functionName, - args, - }; -} - -function extractRpcResult(payload: Record): Record | undefined { - const result = payload.result; - return result && typeof result === 'object' ? result as Record : undefined; -} - -function extractFirstValue(result: Record): unknown { - if (Array.isArray(result.results) && result.results.length > 0) { - const first = result.results[0]; - if (first && typeof first === 'object') { - const record = first as Record; - if ('xdr' in record) return record.xdr; - if ('value' in record) return record.value; - if ('result' in record) return record.result; - } - } - - if ('value' in result) return result.value; - if ('result' in result) return result.result; - if ('balance' in result) return result.balance; - return undefined; -} - -function normalizeBalanceValue(value: unknown): string { - if (typeof value === 'string' && value.trim() !== '') { - return value.trim(); - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return String(Math.trunc(value)); - } - - if (value && typeof value === 'object') { - const record = value as Record; - for (const key of ['balance', 'i128', 'u128', 'value']) { - const candidate = record[key]; - if (typeof candidate === 'string' && candidate.trim() !== '') { - return candidate.trim(); - } - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return String(Math.trunc(candidate)); - } - } - } - - throw new Error('Missing balance value in Soroban RPC response'); -} - -function normalizeTxHash(result: Record): string { - const candidate = result.transactionHash ?? result.txHash ?? result.hash; - if (typeof candidate === 'string' && candidate.trim() !== '') { - return candidate; - } - throw new Error('Missing transaction hash in Soroban RPC response'); -} - -function extractSimulationError(payload: Record): unknown { - if (payload.error) { - return payload.error; - } - - const result = extractRpcResult(payload); - if (!result) { - return undefined; - } - - if (result.error) { - return result.error; - } - - if (Array.isArray(result.results) && result.results.length > 0) { - const first = result.results[0]; - if (first && typeof first === 'object' && 'error' in (first as Record)) { - return (first as Record).error; - } - } - - return undefined; -} - -export class SorobanRpcBillingClient { - private readonly fetchImpl: typeof fetch; - - constructor(private readonly options: SorobanBillingClientOptions) { - this.fetchImpl = options.fetchImpl ?? (env.SOROBAN_CHAOS ? withSorobanLatencyWrapper(fetch) : fetch); - } - - async getBalance(userId: string): Promise { - const result = await this.invoke( - buildSorobanBalanceInvocation( - this.options.contractId, - userId, - this.options.balanceFunctionName ?? 'balance' - ) - ); - - return { - balance: normalizeBalanceValue(extractFirstValue(result)), - }; - } - - async deductBalance( - userId: string, - amount: string, - idempotencyKey?: string - ): Promise { - const result = await this.invoke( - buildSorobanDeductInvocation( - this.options.contractId, - userId, - amount, - idempotencyKey, - this.options.deductFunctionName ?? 'deduct' - ) - ); - - return { - txHash: normalizeTxHash(result), - }; - } - - private async invoke(invocation: SorobanBillingInvocation): Promise> { - const requestId = this.options.requestIdFactory?.() ?? - getOrCreateRequestId(() => `billing-${Date.now()}`); - const requestBody: SorobanBillingRpcRequest = { - jsonrpc: '2.0', - id: requestId, - method: 'simulateTransaction', - params: { - invocation, - sourceAccount: this.options.sourceAccount, - networkPassphrase: this.options.networkPassphrase, - }, - }; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS - ); - - try { - const response = await this.fetchImpl(this.options.rpcUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-request-id': requestId, - }, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - if (!response.ok) { - const message = `Soroban RPC request failed: HTTP ${response.status}`; - throw new SorobanRpcError(message, 'NETWORK_ERROR'); - } - - const payload = await response.json() as Record; - const simulationError = extractSimulationError(payload); - if (simulationError) { - const message = normalizeSorobanBillingError(simulationError, 'Simulation failed'); - throw new SorobanRpcError( - message, - classifyError(message), - extractSimulationDetails(payload) - ); - } - - const result = extractRpcResult(payload); - if (!result) { - throw new SorobanRpcError('Missing result in Soroban RPC response', 'NETWORK_ERROR'); - } - - return result; - } catch (error) { - // Re-throw SorobanRpcError as-is so the category is preserved - if (error instanceof SorobanRpcError) { - throw error; - } - const message = normalizeSorobanBillingError(error, 'Soroban RPC request failed'); - throw new SorobanRpcError(message, classifyError(message)); - } finally { - clearTimeout(timeout); - } - } -} - -export function createSorobanRpcBillingClient( - options: SorobanBillingClientOptions -): SorobanRpcBillingClient { - return new SorobanRpcBillingClient(options); -} diff --git a/src/services/sorobanVaultBillingClient.ts b/src/services/sorobanVaultBillingClient.ts new file mode 100644 index 00000000..4e3f5c45 --- /dev/null +++ b/src/services/sorobanVaultBillingClient.ts @@ -0,0 +1,508 @@ +import { createHash } from "node:crypto"; +import { + Account, + Address, + BASE_FEE, + Contract, + Keypair, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, +} from "@stellar/stellar-sdk"; +import type { Transaction } from "@stellar/stellar-sdk"; +import type { SimulationDetails } from "../lib/simulationDiagnostics.js"; +import type { VaultRepository } from "../repositories/vaultRepository.js"; +import type { + SorobanBalanceResult, + SorobanClient, + SorobanDeductResult, +} from "./billing.js"; +import { + SorobanRpcError, + classifySorobanRpcError, + normalizeSorobanBillingError, +} from "./sorobanBilling.js"; + +/** + * Real Soroban billing client. + * + * Implements the `SorobanClient` interface against the deployed vault ABI + * (`CalloraOrg/Callora-Contracts` `contracts/vault`): + * + * - `balance(env) -> i128` takes no arguments; the result is decoded with + * `scValToNative` and returned as a decimal string of contract units. + * - `deduct(env, caller: Address, amount: i128, request_id: u64)` is built as + * a real `invokeHostFunction` operation, simulated and assembled with + * `rpc.assembleTransaction`, signed with the backend's authorized-caller + * key (`SOROBAN_BILLING_BACKEND_SECRET_KEY`), submitted with + * `sendTransaction`, and confirmed by polling `getTransaction` until the + * bounded deadline. + * + * Error mapping (stable categories consumed by `BillingService` and the + * billing routes): + * + * - `VaultError::InsufficientBalance` (5) → `INSUFFICIENT_BALANCE` + * - `VaultError::Unauthorized` (3), `VaultError::Paused` (4), + * `VaultError::ExceedsMaxDeduct` (7) and any other coded contract failure + * → `CONTRACT_ERROR` with the numeric code in `simulationDetails.errorCode` + * - transport failures → `NETWORK_ERROR` + * - anything that outlives the polling/RPC budget → `TIMEOUT` + * + * Polling budget: every public call runs against a single deadline of + * `requestTimeoutMs` (the configured `SOROBAN_BILLING_RPC_TIMEOUT_MS`), so + * one `deductBalance` invocation — simulate, assemble, send and poll — can + * never outlive `SOROBAN_BILLING_RPC_TIMEOUT_MS × attempts`, the total time + * `BillingService` may spend on the HTTP request. + * + * Idempotency: the on-chain `request_id` is the row-scoped + * `usage_events.onchain_request_id` (allocated from a Postgres sequence when + * the Phase 1 row is inserted). Every retry of the same row re-sends the same + * u64. When the vault reports a request id as already processed + * (`VaultError::DuplicateRequestId` = 29) the client returns the hash of the + * original submission it made in this process (confirming it first), or, if + * the original hash is unknown, fails with `TIMEOUT` so the row stays pending + * for reconciliation instead of being voided or double charged. + * + * No secrets are ever logged: failures carry a normalized message only, and + * the signed XDR is never emitted. + */ + +const DEFAULT_TIMEOUT_MS = 5_000; +const DEFAULT_POLL_INTERVAL_MS = 200; +const DEDUCT_TIMEBOUND_SECONDS = 60; +const MAX_U64 = 18_446_744_073_709_551_615n; + +/** Vault contract error discriminants (Callora-Contracts `src/errors.rs`). */ +export const VAULT_ERROR_NAMES = { + 3: "Unauthorized", + 4: "Paused", + 5: "InsufficientBalance", + 7: "ExceedsMaxDeduct", + 29: "DuplicateRequestId", +} as const; + +const INSUFFICIENT_BALANCE_CODE = 5; +const DUPLICATE_REQUEST_ID_CODE = 29; + +const CONTRACT_ERROR_PATTERN = /Error\(\s*Contract\s*,\s*#(\d+)\s*\)/; + +/** + * Thin view of `rpc.Server` so tests can stub the RPC without the network. + * The real `rpc.Server` satisfies it structurally. + */ +export interface SorobanVaultBillingServer { + getAccount(address: string): Promise; + simulateTransaction( + tx: Transaction, + ): Promise; + sendTransaction( + tx: Transaction, + ): Promise; + getTransaction(hash: string): Promise; +} + +export interface SorobanVaultBillingClientOptions { + /** Soroban RPC endpoint, e.g. https://soroban-testnet.stellar.org */ + rpcUrl: string; + /** Fallback vault contract id (single-vault deployments). */ + contractId: string; + /** Backend authorized-caller secret (SOROBAN_BILLING_BACKEND_SECRET_KEY). */ + secretKey: string; + /** Network passphrase, e.g. "Test SDF Network ; September 2015". */ + networkPassphrase: string; + /** Network name used for VaultRepository lookups (default "testnet"). */ + network?: string; + /** Optional per-user vault lookup; falls back to `contractId`. */ + vaultRepository?: Pick; + /** Total RPC/polling budget per call (SOROBAN_BILLING_RPC_TIMEOUT_MS). */ + requestTimeoutMs?: number; + /** Delay between getTransaction polls (default 200ms). */ + pollIntervalMs?: number; + /** Balance view name (SOROBAN_BILLING_BALANCE_FN, default "balance"). */ + balanceFunctionName?: string; + /** Deduct entrypoint (SOROBAN_BILLING_DEDUCT_FN, default "deduct"). */ + deductFunctionName?: string; + /** Injected RPC server (tests). Defaults to `new rpc.Server(rpcUrl)`. */ + server?: SorobanVaultBillingServer; +} + +/** Extract the numeric discriminant from a `Error(Contract, #N)` message. */ +export function extractVaultErrorCode(message: string): number | undefined { + const match = CONTRACT_ERROR_PATTERN.exec(message); + if (!match) return undefined; + const code = Number(match[1]); + return Number.isInteger(code) ? code : undefined; +} + +/** + * Resolve the u64 that will be sent as `request_id`. + * + * Prefers the row-scoped `onchain_request_id` allocated from the Postgres + * sequence; falls back to a deterministic u64 derived from the idempotency + * key so direct callers still retry with the same id. Zero — the vault's + * "no idempotency" sentinel — is never produced. + */ +export function resolveOnchainRequestId( + onchainRequestId: string | undefined, + idempotencyKey: string | undefined, +): bigint { + if (onchainRequestId !== undefined && onchainRequestId !== null && onchainRequestId !== "") { + const id = BigInt(onchainRequestId); + if (id <= 0n || id > MAX_U64) { + throw new Error( + `onchain_request_id must be a u64 in [1, 2^64-1], received "${onchainRequestId}"`, + ); + } + return id; + } + + if (idempotencyKey !== undefined && idempotencyKey !== "") { + const digest = createHash("sha256").update(idempotencyKey).digest(); + // Map the digest into [1, 2^64-1] — never 0. + return (digest.readBigUInt64BE(0) % (MAX_U64 - 1n)) + 1n; + } + + throw new Error( + "deductBalance requires an onchain_request_id or an idempotencyKey to derive the vault u64 request id", + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class SorobanVaultBillingClient implements SorobanClient { + private readonly server: SorobanVaultBillingServer; + private readonly timeoutMs: number; + private readonly pollIntervalMs: number; + private readonly network: string; + private cachedKeypair: Keypair | undefined; + /** Hashes of submissions whose outcome is not yet known, keyed by u64 id. */ + private readonly submittedTxByRequestId = new Map(); + + constructor(private readonly options: SorobanVaultBillingClientOptions) { + this.server = + options.server ?? new rpc.Server(options.rpcUrl, { allowHttp: true }); + this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS; + this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.network = options.network ?? "testnet"; + } + + private keypair(): Keypair { + if (!this.cachedKeypair) { + this.cachedKeypair = Keypair.fromSecret(this.options.secretKey); + } + return this.cachedKeypair; + } + + private get backendPublicKey(): string { + return this.keypair().publicKey(); + } + + /** + * Resolve the vault contract for a user: their registered vault from + * `VaultRepository.findByUserId`, falling back to the configured + * `SOROBAN_BILLING_CONTRACT_ID` for single-vault deployments. + */ + private async resolveContractId(userId: string): Promise { + const repository = this.options.vaultRepository; + if (!repository) return this.options.contractId; + + try { + const vault = await repository.findByUserId(userId, this.network); + if (vault?.contractId) return vault.contractId; + } catch { + // A lookup failure must not hard-block billing; fall back to the + // configured single-vault contract id below. + } + return this.options.contractId; + } + + /** + * Run one RPC call under the shared deadline. Anything that outlives the + * budget becomes `TIMEOUT`; transport failures become `NETWORK_ERROR` + * (or a better guess from the message). + */ + private async rpcCall(label: string, deadline: number, fn: () => Promise): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new SorobanRpcError( + `${label} exceeded the ${this.timeoutMs}ms Soroban RPC budget`, + "TIMEOUT", + ); + } + + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new SorobanRpcError( + `${label} timed out after ${remaining}ms`, + "TIMEOUT", + ), + ), + remaining, + ); + }), + ]); + } catch (error) { + if (error instanceof SorobanRpcError) throw error; + const message = normalizeSorobanBillingError( + error, + `${label} failed: Soroban RPC request failed`, + ); + throw new SorobanRpcError(message, classifySorobanRpcError(message)); + } finally { + if (timer) clearTimeout(timer); + } + } + + /** Map a failed simulation / contract rejection to a stable category. */ + private mapContractFailure(message: string): SorobanRpcError { + const code = extractVaultErrorCode(message); + const details: SimulationDetails = { + errorCode: code !== undefined ? code : message.slice(0, 300), + errorMessage: message.slice(0, 300), + }; + + if (code === INSUFFICIENT_BALANCE_CODE) { + return new SorobanRpcError(message, "INSUFFICIENT_BALANCE", details); + } + if (code !== undefined) { + // Paused (4), Unauthorized (3), ExceedsMaxDeduct (7) and any other + // typed VaultError → CONTRACT_ERROR with the numeric code exposed. + return new SorobanRpcError(message, "CONTRACT_ERROR", details); + } + return new SorobanRpcError(message, classifySorobanRpcError(message), details); + } + + async getBalance(userId: string): Promise { + const deadline = Date.now() + this.timeoutMs; + const contractId = await this.resolveContractId(userId); + const functionName = this.options.balanceFunctionName ?? "balance"; + + const contract = new Contract(contractId); + // balance(env) takes no arguments; a vacant source account is fine for a + // read-only simulation. + const source = new Account(this.backendPublicKey, "0"); + const transaction = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase: this.options.networkPassphrase, + }) + .addOperation(contract.call(functionName)) + .setTimeout(30) + .build(); + + const simulation = await this.rpcCall( + `simulate ${functionName}`, + deadline, + () => this.server.simulateTransaction(transaction), + ); + + if (rpc.Api.isSimulationError(simulation)) { + throw this.mapContractFailure(simulation.error); + } + if (!simulation.result) { + throw new SorobanRpcError( + `${functionName} simulation returned no result`, + "NETWORK_ERROR", + ); + } + + const native = scValToNative(simulation.result.retval); + if (typeof native !== "bigint") { + throw new SorobanRpcError( + `${functionName} did not return an i128 value`, + "CONTRACT_ERROR", + ); + } + if (native < 0n) { + throw new SorobanRpcError( + `${functionName} returned a negative balance`, + "CONTRACT_ERROR", + ); + } + + return { balance: native.toString() }; + } + + async deductBalance( + userId: string, + amount: string, + idempotencyKey?: string, + onchainRequestId?: string, + ): Promise { + const requestId = resolveOnchainRequestId(onchainRequestId, idempotencyKey); + const requestKey = requestId.toString(); + const units = BigInt(amount); + if (units <= 0n) { + throw new Error(`deduct amount must be positive, received "${amount}"`); + } + + const deadline = Date.now() + this.timeoutMs; + const contractId = await this.resolveContractId(userId); + const functionName = this.options.deductFunctionName ?? "deduct"; + + // 1. Load the backend account for a fresh sequence number. + const account = await this.rpcCall("load backend account", deadline, () => + this.server.getAccount(this.backendPublicKey), + ); + + // 2. Build deduct(caller: Address, amount: i128, request_id: u64). + const contract = new Contract(contractId); + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.options.networkPassphrase, + }) + .addOperation( + contract.call( + functionName, + new Address(this.backendPublicKey).toScVal(), + nativeToScVal(units, { type: "i128" }), + nativeToScVal(requestId, { type: "u64" }), + ), + ) + .setTimeout(DEDUCT_TIMEBOUND_SECONDS) + .build(); + + // 3. Simulate and assemble (footprint, resource fee, auth entries). + const simulation = await this.rpcCall(`simulate ${functionName}`, deadline, () => + this.server.simulateTransaction(transaction), + ); + + if (rpc.Api.isSimulationError(simulation)) { + const failure = this.mapContractFailure(simulation.error); + const code = + typeof failure.simulationDetails?.errorCode === "number" + ? failure.simulationDetails.errorCode + : undefined; + + if (code === DUPLICATE_REQUEST_ID_CODE) { + // The vault already processed this request id: never double charge. + return this.resolveDuplicate(requestId, deadline, failure); + } + // Nothing was submitted. + throw failure; + } + + let prepared: Transaction; + try { + prepared = rpc.assembleTransaction(transaction, simulation).build(); + } catch (error) { + const message = normalizeSorobanBillingError( + error, + "Failed to assemble the deduct transaction", + ); + throw new SorobanRpcError(message, classifySorobanRpcError(message)); + } + + // 4. Sign with the backend's authorized-caller key. + prepared.sign(this.keypair()); + + // 5. Submit. + const sent = await this.rpcCall("send deduct transaction", deadline, () => + this.server.sendTransaction(prepared), + ); + + if (sent.status === "TRY_AGAIN_LATER") { + // Transient by design: BillingService retries with the same request id. + throw new SorobanRpcError( + "sendTransaction returned TRY_AGAIN_LATER", + "NETWORK_ERROR", + ); + } + if (sent.status === "ERROR") { + let resultName = ""; + try { + resultName = sent.errorResult?.result().switch().name ?? ""; + } catch { + resultName = ""; + } + throw new SorobanRpcError( + `sendTransaction failed${resultName ? `: ${resultName}` : ""}`, + "NETWORK_ERROR", + ); + } + + // PENDING (or DUPLICATE — an earlier submission of the same envelope): + // remember the hash so a retry that hits DuplicateRequestId can resolve + // the original transaction instead of guessing. + const hash = sent.hash; + this.submittedTxByRequestId.set(requestKey, hash); + + // 6. Poll for the final status under the shared deadline. + const txHash = await this.pollForReceipt(hash, deadline); + this.submittedTxByRequestId.delete(requestKey); + return { txHash }; + } + + /** + * The vault reports this request id as already processed. Confirm the + * submission we made earlier in this process and return its hash; if we + * have no hash to check, surface an unknown-outcome TIMEOUT so the row + * stays pending for reconciliation (never voided, never double charged). + */ + private async resolveDuplicate( + requestId: bigint, + deadline: number, + failure: SorobanRpcError, + ): Promise { + const knownHash = this.submittedTxByRequestId.get(requestId.toString()); + if (!knownHash) { + throw new SorobanRpcError( + `Vault reports request id ${requestId} as already processed; the original transaction hash is unknown in this process`, + "TIMEOUT", + failure.simulationDetails, + ); + } + + const txHash = await this.pollForReceipt(knownHash, deadline); + this.submittedTxByRequestId.delete(requestId.toString()); + return { txHash }; + } + + /** + * Poll getTransaction until SUCCESS, FAILED, or the deadline. A NOT_FOUND + * that persists past the deadline is a TIMEOUT, never a success. + */ + private async pollForReceipt(hash: string, deadline: number): Promise { + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new SorobanRpcError( + `Transaction ${hash} not confirmed within the ${this.timeoutMs}ms polling budget`, + "TIMEOUT", + ); + } + + const response = await this.rpcCall(`poll transaction ${hash}`, deadline, () => + this.server.getTransaction(hash), + ); + + if (response.status === rpc.Api.GetTransactionStatus.SUCCESS) { + return response.txHash || hash; + } + if (response.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new SorobanRpcError( + `Transaction ${hash} failed on-chain`, + "CONTRACT_ERROR", + { errorMessage: `Transaction ${hash} failed on-chain` }, + ); + } + + // NOT_FOUND — still pending. Wait, then re-check until the deadline. + await sleep(Math.min(this.pollIntervalMs, Math.max(remaining, 0))); + } + } +} + +export function createSorobanVaultBillingClient( + options: SorobanVaultBillingClientOptions, +): SorobanVaultBillingClient { + return new SorobanVaultBillingClient(options); +}