diff --git a/docs/deposit-transaction-builder.md b/docs/deposit-transaction-builder.md index ea041cb6..042469c7 100644 --- a/docs/deposit-transaction-builder.md +++ b/docs/deposit-transaction-builder.md @@ -4,6 +4,8 @@ The deposit transaction builder endpoint allows users to prepare unsigned Stellar/Soroban transactions for depositing USDC into their vault contracts. The backend builds transaction XDR without ever handling user private keys, maintaining a non-custodial architecture. +The transaction is simulated via Soroban RPC and assembled with `prepareTransaction` so it contains `sorobanData` (footprint, resource fees, auth entries). The caller must sign and submit the returned XDR. + ## Endpoint ``` @@ -12,7 +14,7 @@ POST /api/vault/deposit/prepare ### Authentication -Requires authentication via `x-user-id` header. +Requires authentication via `Authorization: Bearer ` header. ### Request Body @@ -42,7 +44,7 @@ Requires authentication via `x-user-id` header. - `source_account` (optional): Custom source account for the transaction - Format: Valid Stellar public key (G... with 56 characters) - - Default: Uses authenticated user's public key + - Default: Uses authenticated user's Stellar address from their profile ### Response @@ -50,40 +52,76 @@ Requires authentication via `x-user-id` header. ```json { - "xdr": "AAAAAgAAAABx...(base64 XDR)...==", - "network": "testnet", - "contractId": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - "amount": "100.0000000", - "operation": { - "type": "invoke_contract", - "function": "deposit", - "args": [ - { - "type": "address", - "value": "GABC..." - }, - { - "type": "i128", - "value": "1000000000" - } - ] + "success": true, + "data": { + "xdr": "AAAAAgAAAABx...(base64 XDR with sorobanData)...==", + "network": "testnet", + "contractId": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "amount": "100.0000000", + "operation": { + "type": "invoke_contract", + "function": "deposit", + "args": [ + { + "type": "address", + "value": "GABC..." + }, + { + "type": "i128", + "value": "1000000000" + } + ] + }, + "metadata": { + "fee": "100", + "timeout": 300, + "resourceFee": "123456", + "minResourceFee": "100000", + "latestLedger": 12345678 + } }, - "metadata": { - "fee": "100", - "timeout": 300 - } + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` +##### Response Fields + +- `xdr`: Base64-encoded unsigned transaction XDR containing `sorobanData` (footprint, resource fees, auth entries) +- `network`: Stellar network (`testnet` or `mainnet`) +- `contractId`: Vault contract ID +- `amount`: Deposit amount in USDC (7 decimal places) +- `operation`: Details of the contract invocation + - `type`: Always `invoke_contract` + - `function`: Always `deposit` + - `args`: Array of arguments (user address and amount in stroops) +- `metadata`: Transaction metadata + - `fee`: Base fee in stroops + - `timeout`: Transaction timeout in seconds + - `resourceFee`: Resource fee from Soroban simulation in stroops + - `minResourceFee`: Minimum resource fee from Soroban simulation in stroops + - `latestLedger`: Latest ledger sequence from Soroban simulation + #### Error Responses ##### 400 Bad Request - Invalid Amount Format ```json { - "error": "Amount must have exactly 7 decimal places (e.g., \"100.0000000\")", - "code": "INVALID_AMOUNT_FORMAT", - "provided": "100.00" + "success": false, + "error": { + "code": "INVALID_AMOUNT_FORMAT", + "message": "Amount must have exactly 7 decimal places (e.g., \"100.0000000\")", + "details": [ + { + "field": "amount_usdc", + "message": "Amount must have exactly 7 decimal places (e.g., \"100.0000000\")", + "code": "custom" + } + ] + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -91,9 +129,13 @@ Requires authentication via `x-user-id` header. ```json { - "error": "network must be either \"testnet\" or \"mainnet\"", - "code": "INVALID_NETWORK", - "provided": "devnet" + "success": false, + "error": { + "code": "INVALID_NETWORK", + "message": "network must be either \"testnet\" or \"mainnet\"" + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -101,9 +143,27 @@ Requires authentication via `x-user-id` header. ```json { - "error": "source_account must be a valid Stellar public key (G...)", - "code": "INVALID_SOURCE_ACCOUNT", - "provided": "invalid_key" + "success": false, + "error": { + "code": "INVALID_SOURCE_ACCOUNT", + "message": "source_account must be a valid Stellar public key (G...)" + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" +} +``` + +##### 400 Bad Request - Stellar Address Required + +```json +{ + "success": false, + "error": { + "code": "STELLAR_ADDRESS_REQUIRED", + "message": "User does not have a Stellar address configured. Please update your profile." + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -111,8 +171,13 @@ Requires authentication via `x-user-id` header. ```json { - "error": "Authentication required", - "code": "UNAUTHORIZED" + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -120,8 +185,13 @@ Requires authentication via `x-user-id` header. ```json { - "error": "Vault not found for user on network 'testnet'. Please create a vault first.", - "code": "VAULT_NOT_FOUND" + "success": false, + "error": { + "code": "VAULT_NOT_FOUND", + "message": "Vault not found for user on network 'testnet'. Please create a vault first." + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -129,8 +199,33 @@ Requires authentication via `x-user-id` header. ```json { - "error": "Invalid vault contract configuration. Please contact support.", - "code": "INVALID_CONTRACT_ID" + "success": false, + "error": { + "code": "INVALID_CONTRACT_ID", + "message": "Invalid vault contract configuration. Please contact support." + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" +} +``` + +##### 502 Bad Gateway - Simulation Failed + +```json +{ + "success": false, + "error": { + "code": "SIMULATION_FAILED", + "message": "Soroban simulation failed. See diagnostics for details.", + "simulationDetails": { + "errorCode": 8, + "errorMessage": "BelowMinDeposit", + "eventCount": 0, + "footprintPresent": true + } + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -138,8 +233,13 @@ Requires authentication via `x-user-id` header. ```json { - "error": "Unable to connect to Stellar network. Please try again later.", - "code": "NETWORK_UNAVAILABLE" + "success": false, + "error": { + "code": "NETWORK_UNAVAILABLE", + "message": "Unable to connect to Stellar network. Please try again later." + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" } ``` @@ -152,7 +252,7 @@ const response = await fetch('/api/vault/deposit/prepare', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-user-id': 'GABC...' + 'Authorization': 'Bearer ' }, body: JSON.stringify({ amount_usdc: '100.0000000', @@ -160,7 +260,8 @@ const response = await fetch('/api/vault/deposit/prepare', { }) }); -const { xdr, network } = await response.json(); +const { data } = await response.json(); +const { xdr, network } = data; ``` ### 2. Sign with Wallet (Freighter Example) @@ -178,7 +279,7 @@ const signedXdr = await window.freighterApi.signTransaction(xdr, { import { Server, TransactionBuilder } from '@stellar/stellar-sdk'; const server = new Server( - network === 'testnet' + network === 'testnet' ? 'https://horizon-testnet.stellar.org' : 'https://horizon.stellar.org' ); @@ -215,6 +316,22 @@ The backend converts the amount to stroops (smallest units) by multiplying by 10 - Decimal precision prevents rounding exploits - Authentication required for all requests +### Stellar Address + +The user's Stellar address is read from their profile (`users.stellar_address`). If not set, the endpoint returns `400 STELLAR_ADDRESS_REQUIRED`. Users must configure their Stellar address before preparing deposits. + +## Simulation and Assembly + +The transaction builder performs the following steps: + +1. **Load source account** from Horizon to get current sequence number +2. **Build transaction** with the deposit contract invocation +3. **Simulate transaction** via Soroban RPC (`simulateTransaction`) to get footprint, resource fees, and auth entries +4. **Prepare transaction** via Soroban RPC (`prepareTransaction`) to assemble the final XDR with `sorobanData` +5. **Return unsigned XDR** with metadata including `resourceFee`, `minResourceFee`, and `latestLedger` + +Simulation errors (including vault contract errors like `BelowMinDeposit (8)`, `Paused (4)`) are mapped to `SimulationError` with redacted diagnostics. + ## Testing Run tests with: @@ -232,10 +349,12 @@ STELLAR_NETWORK=testnet # or 'mainnet' (SOROBAN_NETWORK als # Testnet endpoints/contracts STELLAR_TESTNET_HORIZON_URL=https://horizon-testnet.stellar.org +SOROBAN_TESTNET_RPC_URL=https://soroban-testnet.stellar.org:443 STELLAR_TESTNET_VAULT_CONTRACT_ID=CC...TESTNET_VAULT # Mainnet endpoints/contracts STELLAR_MAINNET_HORIZON_URL=https://horizon.stellar.org +SOROBAN_MAINNET_RPC_URL=https://soroban-mainnet.stellar.org:443 STELLAR_MAINNET_VAULT_CONTRACT_ID=CC...MAINNET_VAULT STELLAR_BASE_FEE=100 # Optional: default 100 stroops @@ -247,6 +366,7 @@ Required configuration for safe transaction building: - `STELLAR_NETWORK` (or `SOROBAN_NETWORK`) must select exactly one active network. - `STELLAR__HORIZON_URL` must point to the matching Horizon instance for that network. +- `SOROBAN__RPC_URL` must point to the matching Soroban RPC instance for that network. - `STELLAR__VAULT_CONTRACT_ID` should be set so the builder can reject mismatched contract IDs. - `STELLAR_BASE_FEE` and `STELLAR_TRANSACTION_TIMEOUT` are optional. If omitted, the builder defaults to `100` stroops and `300` seconds. @@ -257,7 +377,7 @@ Required configuration for safe transaction building: - The builder does not attach a memo unless a valid text memo is provided explicitly - The endpoint is stateless and supports horizontal scaling - Only read operations are performed on the database -- Network calls to Horizon may add latency (target: < 500ms) +- Network calls to Horizon and Soroban RPC may add latency (target: < 500ms) ## Concurrency — Sequence Manager @@ -291,3 +411,6 @@ const sequence = await seqManager.nextSequence(sourceAccountPublicKey); - Different source accounts are serialised independently — one account's load latency does not block another account. +### Sequence Store Behavior + +The sequence store (`PostgresSequenceStore`) is only used for **backend-owned source accounts**. For user-owned accounts (the default when `source_account` is not provided), the sequence store is **not used** — the wallet owns the sequence and submits the transaction directly. This prevents abandoned prepared transactions from burning sequence numbers. \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index 97942141..dcda0dad 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -245,6 +245,333 @@ } } }, + "/api/vault/deposit/prepare": { + "post": { + "summary": "Prepare an unsigned Stellar/Soroban transaction for depositing USDC into a vault", + "operationId": "prepareDepositTransaction", + "description": "Builds an unsigned transaction XDR for the vault deposit function. The transaction is simulated via Soroban RPC and assembled with prepareTransaction so it contains sorobanData (footprint, resource fees, auth entries). The caller must sign and submit the returned XDR. Requires authentication.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "amount_usdc" + ], + "properties": { + "amount_usdc": { + "type": "string", + "pattern": "^\\d+\\.\\d{7}$", + "description": "USDC amount with exactly 7 decimal places (e.g., \"100.0000000\")" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ], + "description": "Stellar network. Defaults to the configured active network." + }, + "source_account": { + "type": "string", + "pattern": "^G[A-Z0-9]{55}$", + "description": "Optional custom source account for the transaction (Stellar public key). Defaults to the authenticated user's Stellar address." + } + } + }, + "examples": { + "basic": { + "summary": "Basic deposit preparation", + "value": { + "amount_usdc": "100.0000000", + "network": "testnet" + } + }, + "withSourceAccount": { + "summary": "Deposit with custom source account", + "value": { + "amount_usdc": "50.0000000", + "network": "testnet", + "source_account": "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Unsigned transaction prepared successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "success", + "data", + "requestId", + "timestamp" + ], + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "data": { + "type": "object", + "required": [ + "xdr", + "network", + "contractId", + "amount", + "operation", + "metadata" + ], + "properties": { + "xdr": { + "type": "string", + "description": "Base64-encoded unsigned transaction XDR with sorobanData" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "contractId": { + "type": "string", + "description": "Vault contract ID" + }, + "amount": { + "type": "string", + "pattern": "^\\d+\\.\\d{7}$", + "description": "Deposit amount in USDC" + }, + "operation": { + "type": "object", + "required": [ + "type", + "function", + "args" + ], + "properties": { + "type": { + "type": "string", + "const": "invoke_contract" + }, + "function": { + "type": "string", + "const": "deposit" + }, + "args": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "i128" + ] + }, + "value": { + "type": "string" + } + } + } + } + } + }, + "metadata": { + "type": "object", + "required": [ + "fee", + "timeout", + "resourceFee", + "minResourceFee", + "latestLedger" + ], + "properties": { + "fee": { + "type": "string", + "description": "Base fee in stroops" + }, + "timeout": { + "type": "integer", + "description": "Transaction timeout in seconds" + }, + "resourceFee": { + "type": "string", + "description": "Resource fee from Soroban simulation in stroops" + }, + "minResourceFee": { + "type": "string", + "description": "Minimum resource fee from Soroban simulation in stroops" + }, + "latestLedger": { + "type": "integer", + "minimum": 0, + "description": "Latest ledger sequence from Soroban simulation" + } + } + } + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "missingAmount": { + "summary": "Missing amount_usdc", + "value": { + "success": false, + "error": { + "code": "MISSING_AMOUNT", + "message": "amount_usdc is required" + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" + } + }, + "invalidAmountFormat": { + "summary": "Invalid amount format", + "value": { + "success": false, + "error": { + "code": "INVALID_AMOUNT_FORMAT", + "message": "Amount must have exactly 7 decimal places (e.g., \"100.0000000\")", + "details": [ + { + "field": "amount_usdc", + "message": "Amount must have exactly 7 decimal places (e.g., \"100.0000000\")", + "code": "custom" + } + ] + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" + } + }, + "invalidNetwork": { + "summary": "Invalid network", + "value": { + "success": false, + "error": { + "code": "INVALID_NETWORK", + "message": "network must be either \"testnet\" or \"mainnet\"" + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" + } + }, + "invalidSourceAccount": { + "summary": "Invalid source account", + "value": { + "success": false, + "error": { + "code": "INVALID_SOURCE_ACCOUNT", + "message": "source_account must be a valid Stellar public key (G...)" + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" + } + } + } + } + } + }, + "401": { + "description": "Authentication is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "The caller has no vault on the requested network", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Soroban simulation or transaction preparation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "simulationFailed": { + "summary": "Soroban simulation failed", + "value": { + "success": false, + "error": { + "code": "SIMULATION_FAILED", + "message": "Soroban simulation failed. See diagnostics for details.", + "simulationDetails": { + "errorCode": 8, + "errorMessage": "BelowMinDeposit", + "eventCount": 0, + "footprintPresent": true + } + }, + "requestId": "req-123", + "timestamp": "2026-01-01T00:00:00.000Z" + } + } + } + } + } + }, + "503": { + "description": "Stellar network or Soroban RPC is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/admin/billing/credits/grant": { "post": { "summary": "Grant GrantFox FWC26 prepaid credits", @@ -8465,8 +8792,7 @@ "type": "object", "required": [ "success", - "requestId", - "timestamp" + "data" ], "properties": { "success": { @@ -8475,13 +8801,7 @@ true ] }, - "requestId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" - } + "data": {} } }, "BillingHistoryRow": { diff --git a/src/config/index.ts b/src/config/index.ts index 5242b3ab..24f54d6b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -264,7 +264,7 @@ export const config = { intervalMs: env.SETTLEMENT_RECON_INTERVAL_MS, }, revenueLedgerIndexer: { - intervalMs: env.REVENUE_LEGDER_INDEXER_INTERVAL_MS, + intervalMs: env.REVENUE_LEDGER_INDEXER_INTERVAL_MS, batchSize: env.REVENUE_LEDGER_INDEXER_BATCH_SIZE, }, @@ -298,11 +298,11 @@ export const config = { billingConcurrency: { maxPerDeveloper: env.BILLING_MAX_CONCURRENCY_PER_DEV, - semaphoreTmlMs: env.BILLING_SEMAPHORE_TTL_MS, + semaphoreTtlMs: env.BILLING_SEMAPHORE_TTL_MS, }, keyConcurrency: { maxPerKey: env.KEY_MAX_CONCURRENCY_PER_KEY, - semaphoreTmlMs: env.KEY_SEMAPHORE_TTL_MS, + semaphoreTtlMs: env.KEY_SEMAPHORE_TTL_MS, }, routeBodyLimits: env.ROUTE_BODY_LIMITS, idempotency: { @@ -322,7 +322,7 @@ export const config = { webhookUrl: env.SLOW_QUERY_ALERT_WEBHOOK_URL, p95ThresholdMs: env.SLOW_QUERY_P95_THRESHOLD_MS, pollIntervalMs: env.SLOW_QUERY_POLL_INTERVAL_MS, - dedupWindowMs: env.SLOW_QUERY_DEBUP_WINDOW_SECONDS * 1000, + dedupWindowMs: env.SLOW_QUERY_DEDUP_WINDOW_SECONDS * 1000, }, memoryAccounting: { @@ -337,7 +337,7 @@ export const config = { windowMs: env.USAGE_ANOMALY_WINDOW_MS, baselineWindows: env.USAGE_ANOMALY_BASELINE_WINDOWS, dedupWindowMs: - env.USAGE_ANOMALY_DETUP_WINDOW_MS ?? env.USAGE_ANOMALY_WINDOW_MS, + env.USAGE_ANOMALY_DEDUP_WINDOW_MS ?? env.USAGE_ANOMALY_WINDOW_MS, }, monthlyInvoiceJob: { @@ -356,7 +356,7 @@ export const config = { Boolean(env.SLO_ALERT_WEBHOOK_URL) && env.SLO_ROUTE_CONFIGS.length > 0, webhookUrl: env.SLO_ALERT_WEBHOOK_URL, pollIntervalMs: env.SLO_ALERT_POLL_INTERVAL_MS, - dedupWindowMs: env.SLO_ALERT_DEUP_WINDOW_MS, + dedupWindowMs: env.SLO_ALERT_DEDUP_WINDOW_MS, observationWindowMs: env.SLO_ALERT_OBSERVATION_WINDOW_MS, configs: env.SLO_ROUTE_CONFIGS as Array<{ method: string; diff --git a/src/controllers/depositController.test.ts b/src/controllers/depositController.test.ts index a05be0ef..244d89e9 100644 --- a/src/controllers/depositController.test.ts +++ b/src/controllers/depositController.test.ts @@ -1,8 +1,9 @@ import { Request, Response } from 'express'; import { DepositController } from './depositController.js'; -import { TransactionBuilderService, InvalidContractIdError, NetworkError } from '../services/transactionBuilder.js'; +import { TransactionBuilderService, InvalidContractIdError, NetworkError, SimulationError } from '../services/transactionBuilder.js'; import { AmountValidator } from '../validators/amountValidator.js'; import type { VaultRepository } from '../repositories/vaultRepository.js'; +import type { UserRepository } from '../repositories/userRepository.js'; // Mock the AmountValidator (fully auto-mocked — it only has static methods, no error classes) jest.mock('../validators/amountValidator.js'); @@ -31,6 +32,7 @@ describe('DepositController', () => { let depositController: DepositController; let mockVaultRepository: jest.Mocked; let mockTransactionBuilder: jest.Mocked; + let mockUserRepository: jest.Mocked; let mockReq: Partial; let mockRes: Partial; let mockLocals: { authenticatedUser: { id: string; email: string } }; @@ -48,8 +50,12 @@ describe('DepositController', () => { buildDepositTransaction: jest.fn(), } as unknown as jest.Mocked; - // Create controller instance - depositController = new DepositController(mockVaultRepository, mockTransactionBuilder); + mockUserRepository = { + findById: jest.fn(), + } as unknown as jest.Mocked; + + // Create controller instance with mocked userRepository + depositController = new DepositController(mockVaultRepository, mockTransactionBuilder, mockUserRepository); // Create mock request/response objects mockReq = { @@ -90,6 +96,16 @@ describe('DepositController', () => { }, fee: '100', timeout: 300, + resourceFee: '123456', + minResourceFee: '100000', + latestLedger: 12345678, + }); + + // Setup default UserRepository mock + mockUserRepository.findById.mockResolvedValue({ + id: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + stellarAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + createdAt: new Date(), }); }); @@ -123,22 +139,41 @@ describe('DepositController', () => { expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.json).toHaveBeenCalledWith( expect.objectContaining({ - xdr: expect.any(String), + success: true, + data: expect.objectContaining({ + xdr: expect.any(String), + network: 'testnet', + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10.0000000', + operation: { + type: 'invoke_contract', + function: 'deposit', + args: expect.arrayContaining([ + expect.objectContaining({ type: 'address' }), + expect.objectContaining({ type: 'i128' }), + ]), + }, + metadata: { + fee: '100', + timeout: 300, + resourceFee: '123456', + minResourceFee: '100000', + latestLedger: 12345678, + }, + }), + requestId: expect.any(String), + timestamp: expect.any(String), + }) + ); + + // Verify TransactionBuilder was called with userStellarAddress from userRepository + expect(mockTransactionBuilder.buildDepositTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + userStellarAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + vaultContractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amountUsdc: '10.0000000', network: 'testnet', - contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - amount: '10.0000000', - operation: { - type: 'invoke_contract', - function: 'deposit', - args: expect.arrayContaining([ - expect.objectContaining({ type: 'address' }), - expect.objectContaining({ type: 'i128' }), - ]), - }, - metadata: { - fee: '100', - timeout: 300, - }, + sourceAccount: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', }) ); }); @@ -154,8 +189,11 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(401); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'Authentication required', code: 'UNAUTHORIZED', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -170,8 +208,11 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'amount_usdc is required', code: 'MISSING_AMOUNT', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -186,8 +227,11 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'amount_usdc must be a string', code: 'INVALID_AMOUNT_TYPE', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -207,9 +251,12 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'Amount must be positive and have at most 7 decimal places', code: 'INVALID_AMOUNT_FORMAT', provided: '10.00', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -224,9 +271,12 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'network must be either "testnet" or "mainnet"', code: 'INVALID_NETWORK', provided: 'invalid', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -267,9 +317,12 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'source_account must be a valid Stellar public key (G...)', code: 'INVALID_SOURCE_ACCOUNT', provided: 'invalid-key', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -286,8 +339,48 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(404); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: "Vault not found for user on network 'testnet'. Please create a vault first.", code: 'VAULT_NOT_FOUND', + requestId: expect.any(String), + timestamp: expect.any(String), + }); + }); + + it('should return 400 when user does not have a Stellar address configured', async () => { + // Arrange + mockReq.body = validRequest; + mockRes.locals = mockLocals; + + mockVaultRepository.findByUserId.mockResolvedValue({ + id: 'vault-123', + userId: mockLocals.authenticatedUser.id, + network: 'testnet', + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + balanceSnapshot: 0n, + lastSyncedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // User without stellar address + mockUserRepository.findById.mockResolvedValue({ + id: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + stellarAddress: '', + createdAt: new Date(), + }); + + // Act + await depositController.prepareDeposit(mockReq as Request, mockRes as Response); + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + success: false, + error: 'User does not have a Stellar address configured. Please update your profile.', + code: 'STELLAR_ADDRESS_REQUIRED', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -317,8 +410,11 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(500); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'Invalid vault contract configuration. Please contact support.', code: 'INVALID_CONTRACT_ID', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -348,10 +444,58 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(503); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'Unable to connect to Stellar network. Please try again later.', code: 'NETWORK_UNAVAILABLE', network: 'Failed to connect to Stellar network', + requestId: expect.any(String), + timestamp: expect.any(String), + }); + }); + + it('should handle SimulationError from TransactionBuilder', async () => { + // Arrange + mockReq.body = validRequest; + mockRes.locals = mockLocals; + + mockVaultRepository.findByUserId.mockResolvedValue({ + id: 'vault-123', + userId: mockLocals.authenticatedUser.id, + network: 'testnet', + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + balanceSnapshot: 0n, + lastSyncedAt: null, + createdAt: new Date(), + updatedAt: new Date(), }); + + mockTransactionBuilder.buildDepositTransaction.mockRejectedValue( + new SimulationError('Soroban simulation failed', { + errorCode: 8, + errorMessage: 'BelowMinDeposit', + events: [], + footprint: { readOnly: [], readWrite: [] }, + }) + ); + + // Act + await depositController.prepareDeposit(mockReq as Request, mockRes as Response); + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(502); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'SIMULATION_FAILED', + message: 'Soroban simulation failed. See diagnostics for details.', + simulationDetails: expect.objectContaining({ + errorCode: 8, + errorMessage: 'BelowMinDeposit', + }), + }), + }) + ); }); it('should handle generic errors gracefully', async () => { @@ -380,8 +524,11 @@ describe('DepositController', () => { // Assert expect(mockRes.status).toHaveBeenCalledWith(500); expect(mockRes.json).toHaveBeenCalledWith({ + success: false, error: 'Failed to prepare deposit transaction', code: 'INTERNAL_ERROR', + requestId: expect.any(String), + timestamp: expect.any(String), }); }); @@ -418,6 +565,9 @@ describe('DepositController', () => { }, fee: '100', timeout: 300, + resourceFee: '123456', + minResourceFee: '100000', + latestLedger: 12345678, }); // Act @@ -430,7 +580,9 @@ describe('DepositController', () => { ); expect(mockRes.json).toHaveBeenCalledWith( expect.objectContaining({ - network: 'mainnet', + data: expect.objectContaining({ + network: 'mainnet', + }), }) ); @@ -438,7 +590,7 @@ describe('DepositController', () => { configMock.config.stellar.network = 'testnet'; }); - it('should work without source_account (uses user public key)', async () => { + it('should work without source_account (uses user stellar address from profile)', async () => { // Arrange mockReq.body = { ...validRequest, source_account: undefined }; mockRes.locals = mockLocals; @@ -460,7 +612,7 @@ describe('DepositController', () => { // Assert expect(mockTransactionBuilder.buildDepositTransaction).toHaveBeenCalledWith( expect.objectContaining({ - userPublicKey: mockLocals.authenticatedUser.id, + userStellarAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', vaultContractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', amountUsdc: '10.0000000', network: 'testnet', @@ -494,4 +646,4 @@ describe('DepositController', () => { }); }); }); -}); +}); \ No newline at end of file diff --git a/src/controllers/depositController.ts b/src/controllers/depositController.ts index ade2f430..afa93475 100644 --- a/src/controllers/depositController.ts +++ b/src/controllers/depositController.ts @@ -12,8 +12,10 @@ import { SourceAccountNotFoundError, TransactionBuildError, SimulationError, + StellarAddressRequiredError, } from '../services/transactionBuilder.js'; import type { VaultRepository } from '../repositories/vaultRepository.js'; +import { defaultUserRepository, type UserRepository } from '../repositories/userRepository.js'; import { config } from '../config/index.js'; import { redactSimulationDetails } from '../lib/simulationDiagnostics.js'; import { successEnvelope, errorEnvelope, getRequestId } from '../lib/envelope.js'; @@ -40,6 +42,9 @@ export interface DepositPrepareResponse { metadata: { fee: string; timeout: number; + resourceFee: string; + minResourceFee: string; + latestLedger: number; }; } @@ -53,7 +58,8 @@ export class VaultNotFoundError extends Error { export class DepositController { constructor( private readonly vaultRepository: VaultRepository, - private readonly transactionBuilder: TransactionBuilderService + private readonly transactionBuilder: TransactionBuilderService, + private readonly userRepository: UserRepository = defaultUserRepository ) {} async prepareDeposit( @@ -164,16 +170,29 @@ export class DepositController { return; } - // Step 7: Build unsigned transaction + // Step 7: Retrieve user's Stellar address + const userRecord = await this.userRepository.findById(user.id); + if (!userRecord || !userRecord.stellarAddress) { + res.status(400).json( + errorEnvelope( + 'STELLAR_ADDRESS_REQUIRED', + 'User does not have a Stellar address configured. Please update your profile.', + requestId + ) + ); + return; + } + + // Step 8: Build unsigned transaction const unsignedTx = await this.transactionBuilder.buildDepositTransaction({ - userPublicKey: user.id, // Assuming user.id is the Stellar public key + userStellarAddress: userRecord.stellarAddress, vaultContractId: vault.contractId, amountUsdc: validation.normalizedAmount!, network, sourceAccount: requestBody.source_account, }); - // Step 8: Construct and return response + // Step 9: Construct and return response const response: DepositPrepareResponse = { xdr: unsignedTx.xdr, network: unsignedTx.network, @@ -187,6 +206,9 @@ export class DepositController { metadata: { fee: unsignedTx.fee, timeout: unsignedTx.timeout, + resourceFee: unsignedTx.resourceFee, + minResourceFee: unsignedTx.minResourceFee, + latestLedger: unsignedTx.latestLedger, }, }; @@ -236,19 +258,33 @@ export class DepositController { ); } else if (error instanceof NetworkError) { res.status(503).json({ + success: false, error: 'Unable to connect to Stellar network. Please try again later.', code: 'NETWORK_UNAVAILABLE', network: error.message, + requestId, + timestamp: new Date().toISOString(), }); } else if (error instanceof SimulationError) { // Log full diagnostics at warning level, but only expose a redacted summary. console.warn('Soroban simulation diagnostics:', error.simulationDetails); const redacted = redactSimulationDetails(error.simulationDetails); res.status(502).json({ + success: false, error: 'Soroban simulation failed. See diagnostics for details.', code: 'SIMULATION_FAILED', simulationDetails: redacted, + requestId, + timestamp: new Date().toISOString(), }); + } else if (error instanceof StellarAddressRequiredError) { + res.status(400).json( + errorEnvelope( + 'STELLAR_ADDRESS_REQUIRED', + 'User does not have a Stellar address configured. Please update your profile.', + requestId + ) + ); } else if (error instanceof TransactionBuildError) { res.status(502).json( errorEnvelope( @@ -268,5 +304,4 @@ export class DepositController { ); } } - -} +} \ No newline at end of file diff --git a/src/metrics.ts b/src/metrics.ts index 934e700e..c1b3b0f2 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -627,7 +627,7 @@ const gatewayApiKeyLookupTotal = new client.Counter({ register.registerMetric(gatewayApiKeyLookupTotal); -export type ApiKeyLookupOutcome = 'hit' | 'miss' | 'revoked' | 'expired'; +export type ApiKeyLookupOutcome = 'hit' | 'miss' | 'revoked' | 'expired' | 'error'; export function recordApiKeyLookup(outcome: ApiKeyLookupOutcome): void { gatewayApiKeyLookupTotal.inc({ outcome }); diff --git a/src/services/createSorobanBillingService.ts b/src/services/createSorobanBillingService.ts index b76ca971..aebb1dea 100644 --- a/src/services/createSorobanBillingService.ts +++ b/src/services/createSorobanBillingService.ts @@ -16,7 +16,7 @@ function resolveBillingRpcUrl(): string { const explicit = env.SOROBAN_BILLING_RPC_URL ?? env.SOROBAN_RPC_URL; if (explicit) return explicit; - const activeRpcUrl = config.stellar.rpcUrl; + const activeRpcUrl = config.stellar.sorobanRpcUrl; if (activeRpcUrl) return activeRpcUrl; if (config.nodeEnv === "development" || config.nodeEnv === "test") { @@ -64,7 +64,7 @@ export function createSorobanBillingService( networkPassphrase: resolveBillingNetworkPassphrase(), requestTimeoutMs: env.SOROBAN_BILLING_RPC_TIMEOUT_MS, balanceFunctionName: env.SOROBAN_BILLING_BALANCE_FN, - deductFunctionName: env.SOROBAN_BILLING_DETUCT_FN, + deductFunctionName: env.SOROBAN_BILLING_DEDUCT_FN, }), ); return processBillingService; diff --git a/src/services/transactionBuilder.smoke.test.ts b/src/services/transactionBuilder.smoke.test.ts index 97ac537a..2e975b25 100644 --- a/src/services/transactionBuilder.smoke.test.ts +++ b/src/services/transactionBuilder.smoke.test.ts @@ -179,7 +179,7 @@ describe('TransactionBuilderService — smoke tests (extended)', () => { test('smoke: happy path returns a valid unsigned XDR transaction', async () => { const result = await makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: VALID_AMOUNT, }); @@ -201,7 +201,7 @@ describe('TransactionBuilderService — smoke tests (extended)', () => { const createServer = jest.fn().mockReturnValue(fakeServer); await makeService({ createServer }).buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: VALID_AMOUNT, }); @@ -214,7 +214,7 @@ describe('TransactionBuilderService — smoke tests (extended)', () => { test('smoke: whitespace-only memo is treated as absent (no memo added)', async () => { const result = await makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: VALID_AMOUNT, memoText: ' ', @@ -226,7 +226,7 @@ describe('TransactionBuilderService — smoke tests (extended)', () => { test('smoke: null memo is treated as absent (no memo added)', async () => { const result = await makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: VALID_AMOUNT, memoText: null, @@ -238,7 +238,7 @@ describe('TransactionBuilderService — smoke tests (extended)', () => { test('smoke: amount with maximum allowed value builds successfully', async () => { const result = await makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: '1000000000.0000000', // exactly 1 billion USDC }); @@ -256,7 +256,7 @@ describe('TransactionBuilderService — smoke tests (extended)', () => { test('smoke: rejects mismatched vault contract ID with NetworkError before account load', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: 'BADCONTRACT', amountUsdc: VALID_AMOUNT, }), @@ -269,7 +269,7 @@ test('smoke: rejects mismatched vault contract ID with NetworkError before accou test('smoke: rejects bad user public key with InvalidStellarAddressError', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: 'BADKEY', + userStellarAddress: 'BADKEY', vaultContractId: VALID_VAULT, amountUsdc: VALID_AMOUNT, }), @@ -282,7 +282,7 @@ test('smoke: rejects mismatched vault contract ID with NetworkError before accou test('smoke: rejects zero-value amount with InvalidAmountError', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: '0.0000000', }), @@ -295,7 +295,7 @@ test('smoke: rejects mismatched vault contract ID with NetworkError before accou test('smoke: rejects amount missing decimal part with InvalidAmountError', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: '100', }), @@ -306,7 +306,7 @@ test('smoke: rejects mismatched vault contract ID with NetworkError before accou test('smoke: rejects amount with fewer than 7 decimal places with InvalidAmountError', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: VALID_VAULT, amountUsdc: '1.000', }), @@ -319,7 +319,7 @@ test('smoke: rejects mismatched vault contract ID with NetworkError before accou test('smoke: rejects request network that differs from configured network', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: 'CVAULTMAIN', amountUsdc: VALID_AMOUNT, network: 'mainnet', // config is set to testnet @@ -333,7 +333,7 @@ test('smoke: rejects mismatched vault contract ID with NetworkError before accou test('smoke: rejects vault contract ID that does not match configured ID', async () => { await assert.rejects( makeService().buildDepositTransaction({ - userPublicKey: VALID_USER_KEY, + userStellarAddress: VALID_USER_KEY, vaultContractId: 'CDIFFERENTVAULT', amountUsdc: VALID_AMOUNT, }), diff --git a/src/services/transactionBuilder.test.ts b/src/services/transactionBuilder.test.ts index a59e4230..447d3e15 100644 --- a/src/services/transactionBuilder.test.ts +++ b/src/services/transactionBuilder.test.ts @@ -16,6 +16,9 @@ const mockAddMemo = jest.fn(); const mockSetTimeout = jest.fn(); const mockBuild = jest.fn(); +const mockSimulateTransaction = jest.fn(); +const mockPrepareTransaction = jest.fn(); + class MockAddress { constructor(private readonly value: string) { if (typeof value !== 'string' || value.trim() === '' || value.startsWith('BAD')) { @@ -77,6 +80,20 @@ class MockTransactionBuilder { } } +class MockRpcServer { + constructor(url: string, options: unknown) { + // Store for verification + } + + simulateTransaction(tx: unknown) { + return mockSimulateTransaction(tx); + } + + prepareTransaction(tx: unknown, simulationResult: unknown) { + return mockPrepareTransaction(tx, simulationResult); + } +} + jest.mock('@stellar/stellar-sdk', () => ({ Horizon: { Server: MockServer, @@ -90,6 +107,12 @@ jest.mock('@stellar/stellar-sdk', () => ({ text: mockMemoText, }, nativeToScVal: mockNativeToScVal, + rpc: { + Server: MockRpcServer, + Api: { + isSimulationSuccess: (result: { error?: unknown }) => !result.error, + }, + }, })); jest.mock('../config/index.js', () => ({ @@ -101,11 +124,13 @@ jest.mock('../config/index.js', () => ({ networks: { testnet: { horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org:443', networkPassphrase: 'Test SDF Network ; September 2015', vaultContractId: 'CVAULTTEST', }, mainnet: { horizonUrl: 'https://horizon.stellar.org', + sorobanRpcUrl: 'https://soroban-mainnet.stellar.org:443', networkPassphrase: 'Public Global Stellar Network ; September 2015', vaultContractId: 'CVAULTMAIN', }, @@ -122,6 +147,7 @@ import { SourceAccountNotFoundError, TransactionBuildError, TransactionBuilderService, + StellarAddressRequiredError, } from './transactionBuilder.js'; describe('TransactionBuilderService', () => { @@ -153,13 +179,33 @@ describe('TransactionBuilderService', () => { toXDR: () => `xdr:${options.fee}:${timeout}:${memo?.value ?? 'none'}:${String(operation.contract)}`, })); + + mockSimulateTransaction.mockResolvedValue({ + result: { + retval: 'success', + }, + events: [], + footprint: { readOnly: [], readWrite: [] }, + latestLedger: 12345678, + minResourceFee: '100000', + }); + + mockPrepareTransaction.mockResolvedValue({ + transaction: { + signatures: [], + toXDR: () => 'prepared-xdr-with-soroban-data', + }, + resourceFee: '123456', + minResourceFee: '100000', + latestLedger: 12345678, + }); }); test('builds an unsigned transaction with configured fee and timeout defaults', async () => { const service = new TransactionBuilderService(); const result = await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '12.3456789', }); @@ -167,21 +213,30 @@ describe('TransactionBuilderService', () => { assert.equal(result.network, 'testnet'); assert.equal(result.fee, '100'); assert.equal(result.timeout, 300); + assert.equal(result.resourceFee, '123456'); + assert.equal(result.minResourceFee, '100000'); + assert.equal(result.latestLedger, 12345678); assert.equal(result.memo, undefined); assert.equal(result.operation.args[1]?.value, '123456789'); - assert.equal(result.xdr, 'xdr:100:300:none:CVAULTTEST'); + assert.equal(result.xdr, 'prepared-xdr-with-soroban-data'); assert.equal(mockServerConstructor.mock.calls[0]?.[0], 'https://horizon-testnet.stellar.org'); assert.equal(mockAddMemo.mock.calls.length, 0); + // Verify simulateTransaction and prepareTransaction were called + assert.equal(mockSimulateTransaction.mock.calls.length, 1); + assert.equal(mockPrepareTransaction.mock.calls.length, 1); }); - test('uses a durable reserved sequence when a sequence store is configured', async () => { + test('uses a durable reserved sequence when a sequence store is configured for backend account', async () => { const sequenceStore = { allocate: jest.fn().mockResolvedValue(9n), }; - const service = new TransactionBuilderService({ sequenceStore }); + const service = new TransactionBuilderService({ + sequenceStore, + isBackendOwnedAccount: () => true, + }); await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '12.3456789', }); @@ -190,16 +245,36 @@ describe('TransactionBuilderService', () => { assert.equal(mockBuild.mock.calls[0]?.[0].sourceAccount.sequence, '8'); }); + test('does not use sequence store for user-owned accounts', async () => { + const sequenceStore = { + allocate: jest.fn().mockResolvedValue(9n), + }; + const service = new TransactionBuilderService({ + sequenceStore, + isBackendOwnedAccount: () => false, + }); + + await service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '12.3456789', + }); + + // Sequence store should not be called for user-owned accounts + assert.equal(sequenceStore.allocate.mock.calls.length, 0); + }); + test('returns an explicit recoverable network error when durable sequence allocation fails', async () => { const service = new TransactionBuilderService({ sequenceStore: { allocate: jest.fn().mockRejectedValue(new Error('database unavailable')), }, + isBackendOwnedAccount: () => true, }); await expect( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '12.3456789', }) @@ -210,18 +285,11 @@ describe('TransactionBuilderService', () => { const service = new TransactionBuilderService(); await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '10.0000000', }); - // Check that the TransactionBuilder receives the correct options. - // We check mockBuild which receives the options via the MockTransactionBuilder instance. - - // Since I can't easily access arguments of the mock constructor in this setup - // because of how MockTransactionBuilder is defined, I'll check mockBuild - // which receives the options via the MockTransactionBuilder instance. - const buildCall = mockBuild.mock.calls[0]?.[0]; assert.equal(buildCall.options.networkPassphrase, 'Test SDF Network ; September 2015'); }); @@ -233,7 +301,7 @@ describe('TransactionBuilderService', () => { }); const result = await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', memoText: ' deposit ', @@ -254,7 +322,7 @@ describe('TransactionBuilderService', () => { await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1e7', }), @@ -269,7 +337,7 @@ describe('TransactionBuilderService', () => { await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1000000001.0000000', }), @@ -282,7 +350,7 @@ describe('TransactionBuilderService', () => { await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', memoText: '12345678901234567890123456789', @@ -297,7 +365,7 @@ describe('TransactionBuilderService', () => { await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', sourceAccount: 'GSOURCEACCOUNT123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', @@ -307,13 +375,12 @@ describe('TransactionBuilderService', () => { }); test('maps Horizon transport failures to NetworkError', async () => { - // mockRejectedValue (not Once) so all retry attempts fail const service = new TransactionBuilderService({ maxRetries: 0 }); mockLoadAccount.mockRejectedValueOnce(new Error('connect ETIMEDOUT')); await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', sourceAccount: 'GSOURCEACCOUNT123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', @@ -329,7 +396,7 @@ describe('TransactionBuilderService', () => { .mockResolvedValueOnce({ accountId: 'GSOURCEACCOUNT123', sequence: '1' }); const result = await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }); @@ -344,7 +411,7 @@ describe('TransactionBuilderService', () => { await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }), @@ -362,7 +429,7 @@ describe('TransactionBuilderService', () => { await assert.rejects( service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }), @@ -385,7 +452,7 @@ describe('TransactionBuilderService', () => { }); const error = await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }).catch((err) => err); @@ -408,7 +475,7 @@ describe('TransactionBuilderService', () => { }); const error = await service.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }).catch((err) => err); @@ -419,13 +486,83 @@ describe('TransactionBuilderService', () => { }); }); + test('handles Soroban simulation failure (non-success result)', async () => { + const service = new TransactionBuilderService(); + mockSimulateTransaction.mockResolvedValueOnce({ + error: 'BelowMinDeposit', + errorCode: 8, + }); + + const error = await service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '1.0000000', + }).catch((err) => err); + + assert.ok(error instanceof SimulationError); + assert.equal(error.simulationDetails.errorCode, 8); + assert.equal(error.simulationDetails.errorMessage, 'BelowMinDeposit'); + }); + + test('handles prepareTransaction failure', async () => { + const service = new TransactionBuilderService(); + mockPrepareTransaction.mockRejectedValueOnce(new Error('prepare failed')); + + const error = await service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '1.0000000', + }).catch((err) => err); + + assert.ok(error instanceof SimulationError); + assert.ok(error.message.includes('prepareTransaction failed')); + }); + + test('retries transient RPC errors during simulation', async () => { + const service = new TransactionBuilderService({ maxRetries: 1, retryBaseDelayMs: 0 }); + mockSimulateTransaction + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce({ + result: { retval: 'success' }, + events: [], + footprint: { readOnly: [], readWrite: [] }, + latestLedger: 12345678, + minResourceFee: '100000', + }); + + const result = await service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '1.0000000', + }); + + assert.equal(result.network, 'testnet'); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + }); + + test('does not retry permanent RPC errors during simulation', async () => { + const service = new TransactionBuilderService({ maxRetries: 3, retryBaseDelayMs: 0 }); + mockSimulateTransaction.mockRejectedValue(new Error('404 not found')); + + await assert.rejects( + service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '1.0000000', + }), + SimulationError + ); + // Should not retry 404 + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + }); + test('rejects invalid fee or timeout overrides', async () => { const badFeeService = new TransactionBuilderService({ baseFee: '0' }); const badTimeoutService = new TransactionBuilderService({ timeoutSeconds: 0 }); await assert.rejects( badFeeService.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }), @@ -434,11 +571,39 @@ describe('TransactionBuilderService', () => { await assert.rejects( badTimeoutService.buildDepositTransaction({ - userPublicKey: 'GUSERPUBLICKEY123', + userStellarAddress: 'GUSERPUBLICKEY123', vaultContractId: 'CVAULTTEST', amountUsdc: '1.0000000', }), TransactionBuildError ); }); -}); + + test('uses custom RPC server when provided', async () => { + const customRpcServer = new MockRpcServer('custom-url', {}); + const createRpcServer = jest.fn().mockReturnValue(customRpcServer); + const service = new TransactionBuilderService({ createRpcServer }); + + await service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '1.0000000', + }); + + assert.equal(createRpcServer.mock.calls[0]?.[0], 'https://soroban-testnet.stellar.org:443'); + }); + + test('uses custom Horizon server when provided', async () => { + const customServer = new MockServer('custom-horizon-url'); + const createServer = jest.fn().mockReturnValue(customServer); + const service = new TransactionBuilderService({ createServer }); + + await service.buildDepositTransaction({ + userStellarAddress: 'GUSERPUBLICKEY123', + vaultContractId: 'CVAULTTEST', + amountUsdc: '1.0000000', + }); + + assert.equal(createServer.mock.calls[0]?.[0], 'https://horizon-testnet.stellar.org'); + }); +}); \ No newline at end of file diff --git a/src/services/transactionBuilder.ts b/src/services/transactionBuilder.ts index 9a977fa2..cbe112ac 100644 --- a/src/services/transactionBuilder.ts +++ b/src/services/transactionBuilder.ts @@ -5,7 +5,10 @@ import { Address, Memo, nativeToScVal, + rpc, + Transaction, } from '@stellar/stellar-sdk'; +import { assembleTransaction } from '@stellar/stellar-sdk/rpc'; import { config } from '../config/index.js'; import { withRetry } from '../lib/retry.js'; import { @@ -18,7 +21,7 @@ import { SequenceAllocationError, type SequenceStore } from './sequenceManager.j export type StellarNetwork = 'testnet' | 'mainnet'; export interface BuildDepositParams { - userPublicKey: string; + userStellarAddress: string; vaultContractId: string; amountUsdc: string; network?: StellarNetwork; @@ -49,6 +52,9 @@ export interface UnsignedTransaction { operation: TransactionOperation; fee: string; timeout: number; + resourceFee: string; + minResourceFee: string; + latestLedger: number; memo?: TransactionMemo; } @@ -98,9 +104,6 @@ export class NetworkError extends Error { } } -/** - * Base class for all transaction‑building failures. - */ export class TransactionBuildError extends Error { constructor(message: string) { super(message); @@ -108,13 +111,7 @@ export class TransactionBuildError extends Error { } } -/** - * Thrown when a Soroban simulation (e.g. during contract invocation) fails. - * Carries the raw simulation diagnostics so callers can surface them without - * exposing secrets. - */ export class SimulationError extends TransactionBuildError { - /** Structured diagnostics returned by the Soroban RPC simulation endpoint. */ public readonly simulationDetails: SimulationDetails; constructor(message: string, simulationDetails: unknown) { @@ -124,6 +121,13 @@ export class SimulationError extends TransactionBuildError { } } +export class StellarAddressRequiredError extends Error { + constructor() { + super('User does not have a Stellar address configured'); + this.name = 'StellarAddressRequiredError'; + } +} + /* -------------------------------------------------------------------------- */ /* Service implementation */ /* -------------------------------------------------------------------------- */ @@ -139,29 +143,40 @@ interface NormalizedMemo { export interface TransactionBuilderServiceOptions { createServer?: (horizonUrl: string) => HorizonAccountLoader; + createRpcServer?: (rpcUrl: string) => rpc.Server; sequenceStore?: SequenceStore | false; sequenceAllocationTimeoutMs?: number; baseFee?: string | number; timeoutSeconds?: number; - /** Maximum number of retries for transient Horizon errors. Default: 3. */ maxRetries?: number; - /** Base delay in ms for exponential backoff. Default: 500. */ retryBaseDelayMs?: number; + isBackendOwnedAccount?: (accountId: string) => boolean; } const DEFAULT_MAX_RETRIES = 3; const DEFAULT_RETRY_BASE_DELAY_MS = 500; -/** - * Returns true for errors that represent a transient Horizon failure worth retrying. - * 404 / account-not-found is a permanent state — retrying won't help. - */ function isHorizonTransientError(error: unknown): boolean { if (error instanceof DOMException && error.name === 'AbortError') return false; const msg = (error instanceof Error ? error.message : String(error)).toLowerCase(); return !msg.includes('404') && !msg.includes('not found') && !msg.includes('resource missing'); } +function isRpcTransientError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') return false; + const msg = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return ( + msg.includes('timeout') || + msg.includes('econnreset') || + msg.includes('etimedout') || + msg.includes('econnrefused') || + msg.includes('network') || + msg.includes('503') || + msg.includes('502') || + msg.includes('504') + ); +} + export class TransactionBuilderService { private static readonly DEFAULT_TRANSACTION_TIMEOUT = 300; private static readonly USDC_STROOPS_MULTIPLIER = 10_000_000n; @@ -191,15 +206,16 @@ export class TransactionBuilderService { ); } - const { networkPassphrase, horizonUrl } = this.getNetworkConfig(selectedNetwork); + const { networkPassphrase, horizonUrl, sorobanRpcUrl } = this.getNetworkConfig(selectedNetwork); const fee = this.resolveFee(); const timeout = this.resolveTimeout(); const server = this.createServer(horizonUrl); + const rpcServer = this.createRpcServer(sorobanRpcUrl); - const sourceKey = params.sourceAccount ?? params.userPublicKey; + const sourceKey = params.sourceAccount ?? params.userStellarAddress; const amountStroops = this.convertUsdcToStroops(params.amountUsdc); const contractAddress = this.parseContractAddress(params.vaultContractId); - const userAddress = this.parseStellarAddress(params.userPublicKey, 'user public key'); + const userAddress = this.parseStellarAddress(params.userStellarAddress, 'user stellar address'); this.parseStellarAddress(sourceKey, 'source account'); const memo = this.createMemo(params.memoText); @@ -214,6 +230,8 @@ export class TransactionBuilderService { shouldRetry: isHorizonTransientError, } ); + + const isBackendAccount = this.options.isBackendOwnedAccount?.(sourceKey) ?? false; const sequenceStore = this.options.sequenceStore === undefined ? process.env.NODE_ENV === 'test' @@ -221,7 +239,7 @@ export class TransactionBuilderService { : new PostgresSequenceStore() : this.options.sequenceStore || undefined; - if (sequenceStore) { + if (sequenceStore && isBackendAccount) { const accountSequence = (sourceAccount as { sequence?: unknown }).sequence; const ledgerNextSequence = BigInt(String(accountSequence)) + 1n; const reservedSequence = await this.withSequenceAllocationTimeout( @@ -244,8 +262,6 @@ export class TransactionBuilderService { ], }); } catch (error) { - // If the SDK throws a simulation‑related error we capture its diagnostics. - // The SDK currently throws a generic Error with a `details` property. const details = (error as { details?: unknown }).details; if (details) { throw new SimulationError( @@ -258,6 +274,7 @@ export class TransactionBuilderService { ); } + let transaction: Transaction; try { let builder = new TransactionBuilder( sourceAccount as ConstructorParameters[0], @@ -271,54 +288,119 @@ export class TransactionBuilderService { builder = builder.addMemo(memo.sdkMemo); } - const transaction = builder.setTimeout(timeout).build(); + transaction = builder.setTimeout(timeout).build(); if (transaction.signatures.length !== 0) { throw new TransactionBuildError('Transaction should not have signatures'); } - - return { - xdr: transaction.toXDR(), - network: selectedNetwork, - operation: { - type: 'invoke_contract', - contractId: params.vaultContractId, - function: 'deposit', - args: [ - { type: 'address', value: params.userPublicKey }, - { type: 'i128', value: amountStroops.toString() }, - ], - }, - fee, - timeout, - ...(memo - ? { - memo: { - type: 'text' as const, - value: memo.value, - }, - } - : {}), - }; } catch (error) { if (error instanceof TransactionBuildError) { throw error; } - throw new TransactionBuildError( `Failed to build Stellar transaction: ${this.getErrorMessage(error)}` ); } + + const { preparedTx, simulationResult } = await this.simulateAndPrepareTransaction( + rpcServer, + transaction, + networkPassphrase + ); + + return { + xdr: preparedTx.toXDR(), + network: selectedNetwork, + operation: { + type: 'invoke_contract', + contractId: params.vaultContractId, + function: 'deposit', + args: [ + { type: 'address', value: params.userStellarAddress }, + { type: 'i128', value: amountStroops.toString() }, + ], + }, + fee, + timeout, + resourceFee: simulationResult.minResourceFee ?? '0', + minResourceFee: simulationResult.minResourceFee ?? '0', + latestLedger: simulationResult.latestLedger ?? 0, + ...(memo + ? { + memo: { + type: 'text' as const, + value: memo.value, + }, + } + : {}), + }; + } + + private async simulateAndPrepareTransaction( + rpcServer: rpc.Server, + transaction: Transaction, + networkPassphrase: string + ): Promise<{ + preparedTx: Transaction; + simulationResult: rpc.Api.SimulateTransactionSuccessResponse; + }> { + let simulatedTx: rpc.Api.SimulateTransactionResponse; + + try { + simulatedTx = await withRetry( + () => rpcServer.simulateTransaction(transaction), + { + maxAttempts: (this.options.maxRetries ?? DEFAULT_MAX_RETRIES) + 1, + baseDelayMs: this.options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS, + shouldRetry: isRpcTransientError, + } + ); + } catch (error) { + throw new SimulationError( + `Soroban simulation failed: ${this.getErrorMessage(error)}`, + error + ); + } + + if (!rpc.Api.isSimulationSuccess(simulatedTx)) { + throw new SimulationError( + 'Soroban simulation failed', + simulatedTx + ); + } + + let preparedTx: Transaction; + + try { + const builder = assembleTransaction(transaction, simulatedTx); + preparedTx = builder.build(); + } catch (error) { + throw new SimulationError( + `Soroban assembleTransaction failed: ${this.getErrorMessage(error)}`, + error + ); + } + + if (preparedTx.signatures.length !== 0) { + throw new TransactionBuildError('Prepared transaction should not have signatures'); + } + + return { + preparedTx, + simulationResult: simulatedTx, + }; } private getNetworkConfig(network: StellarNetwork): { networkPassphrase: string; horizonUrl: string; + sorobanRpcUrl: string; } { const networkConfig = config.stellar.networks[network]; return { networkPassphrase: networkConfig.networkPassphrase, horizonUrl: networkConfig.horizonUrl, + sorobanRpcUrl: networkConfig.sorobanRpcUrl, }; } @@ -326,14 +408,18 @@ export class TransactionBuilderService { return this.options.createServer?.(horizonUrl) ?? new Horizon.Server(horizonUrl); } + private createRpcServer(rpcUrl: string): rpc.Server { + return this.options.createRpcServer?.(rpcUrl) ?? new rpc.Server(rpcUrl, { allowHttp: true }); + } + private resolveFee(): string { const configuredFee = this.options.baseFee ?? config.stellar.baseFee; const parsedFee = typeof configuredFee === 'number' ? configuredFee : /^\d+$/.test(configuredFee) - ? Number.parseInt(configuredFee, 10) - : Number.NaN; + ? Number.parseInt(configuredFee, 10) + : Number.NaN; if (!Number.isSafeInteger(parsedFee) || parsedFee <= 0) { throw new TransactionBuildError('Invalid Stellar base fee configuration'); @@ -486,4 +572,4 @@ export class TransactionBuilderService { } } } -} +} \ No newline at end of file