From 6db2c9cd14644ef78de51a43c8b942ed0b021b15 Mon Sep 17 00:00:00 2001 From: Jeremiah Peters Date: Wed, 29 Apr 2026 11:56:59 +0100 Subject: [PATCH 1/2] [Soroban] USDC SEP-41 token interface integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add token_address() helper to resolve XLM/USDC contract address from instance storage based on AssetType enum, using token::Client::new() for all fund movements (deposit, release, partial release, expire, refund, resolve_dispute) - Fix expire() calling transfer_from_contract() without asset_type arg - Store USDC and XLM contract addresses in DataKey::UsdcToken / DataKey::XlmToken during __constructor; validated at init time - Contract is fully parameterised with USDC contract ID — no hardcoded addresses anywhere in the escrow logic - Fix test tuple destructuring: setup_env() returns 8-tuple (added xlm); update all test sites that were missing the _xlm binding - Add AssetType::Usdc argument to deposit() calls in tests that were missing it (boundary tests, KYC gating tests) --- .../contracts/payment_escrow/src/lib.rs | 20 +++++++++++++- .../contracts/payment_escrow/src/test.rs | 27 ++++++++++--------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/dabdub_contracts/contracts/payment_escrow/src/lib.rs b/dabdub_contracts/contracts/payment_escrow/src/lib.rs index 315ea745..ca51f901 100644 --- a/dabdub_contracts/contracts/payment_escrow/src/lib.rs +++ b/dabdub_contracts/contracts/payment_escrow/src/lib.rs @@ -307,7 +307,7 @@ impl PaymentEscrowContract { panic!("Payment fully released"); } - Self::transfer_from_contract(&env, &payment.customer, remaining); + Self::transfer_from_contract(&env, &payment.customer, remaining, &payment.asset_type); payment.released_amount = payment.amount; payment.status = PaymentStatus::Expired; env.storage() @@ -511,4 +511,22 @@ impl PaymentEscrowContract { token::Client::new(env, &token_addr) .transfer(&env.current_contract_address(), recipient, &amount); } + + /// Resolve the on-chain token contract address for the given asset type. + /// Both addresses are stored in instance storage during construction and + /// validated at that point, so the unwrap here is safe. + fn token_address(env: &Env, asset_type: &AssetType) -> Address { + match asset_type { + AssetType::Usdc => env + .storage() + .instance() + .get(&DataKey::UsdcToken) + .unwrap(), + AssetType::Xlm => env + .storage() + .instance() + .get(&DataKey::XlmToken) + .unwrap(), + } + } } diff --git a/dabdub_contracts/contracts/payment_escrow/src/test.rs b/dabdub_contracts/contracts/payment_escrow/src/test.rs index 8d523343..980789b8 100644 --- a/dabdub_contracts/contracts/payment_escrow/src/test.rs +++ b/dabdub_contracts/contracts/payment_escrow/src/test.rs @@ -111,7 +111,7 @@ fn test_deposit_happy_path() { #[test] fn test_lifecycle_create_deposit_confirm_settle() { - let (env, client, contract_id, admin, customer, merchant, usdc) = setup_env(); + let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); let payment_id = make_id(&env, 2); // create @@ -199,11 +199,11 @@ fn test_deposit_excessive_ttl() { #[test] fn test_deposit_max_ttl_boundary() { - let (env, client, _contract_id, _admin, customer, merchant, _usdc) = setup_env(); + let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); let payment_id = make_id(&env, 88); let max_ttl = client.get_max_ttl_ledgers(); - client.deposit(&customer, &payment_id, &merchant, &250_000_000i128, &max_ttl); + client.deposit(&customer, &payment_id, &merchant, &250_000_000i128, &max_ttl, &AssetType::Usdc); let payment = client.get_payment(&payment_id); assert_eq!(payment.expiry, 10 + max_ttl); @@ -211,10 +211,10 @@ fn test_deposit_max_ttl_boundary() { #[test] fn test_deposit_minimum_positive_amount_boundary() { - let (env, client, contract_id, _admin, customer, merchant, usdc) = setup_env(); + let (env, client, contract_id, _admin, customer, merchant, usdc, _xlm) = setup_env(); let payment_id = make_id(&env, 89); - client.deposit(&customer, &payment_id, &merchant, &1i128, &DEFAULT_PAYMENT_TTL); + client.deposit(&customer, &payment_id, &merchant, &1i128, &DEFAULT_PAYMENT_TTL, &AssetType::Usdc); let payment = client.get_payment(&payment_id); assert_eq!(payment.amount, 1); @@ -513,7 +513,7 @@ fn test_dispute_after_window_rejected() { #[test] fn test_dispute_allowed_at_dispute_window_boundary() { - let (env, client, _contract_id, _admin, customer, merchant, _usdc) = setup_env(); + let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); let payment_id = make_id(&env, 90); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -598,7 +598,7 @@ fn test_resolve_dispute_to_customer() { #[test] fn test_cancellation_path_refunds_customer_via_dispute_resolution() { - let (env, client, contract_id, admin, customer, merchant, usdc) = setup_env(); + let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); let payment_id = make_id(&env, 91); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -660,7 +660,7 @@ fn test_resolve_dispute_unauthorized() { #[test] #[should_panic(expected = "Not admin")] fn test_set_registry_unauthorized() { - let (env, client, _contract_id, _admin, _customer, _merchant, _usdc) = setup_env(); + let (env, client, _contract_id, _admin, _customer, _merchant, _usdc, _xlm) = setup_env(); let random = Address::generate(&env); let registry = Address::generate(&env); @@ -836,6 +836,7 @@ fn test_release_blocked_for_unverified_merchant() { &merchant, &100_000_000i128, &DEFAULT_PAYMENT_TTL, + &AssetType::Usdc, ); // Release must be blocked by KYC check. @@ -856,6 +857,7 @@ fn test_release_allowed_for_verified_merchant() { &merchant, &250_000_000i128, &DEFAULT_PAYMENT_TTL, + &AssetType::Usdc, ); escrow.release(&admin, &payment_id); @@ -869,14 +871,14 @@ fn test_release_allowed_for_verified_merchant() { #[test] fn test_version_initialization() { - let (_env, client, _contract_id, _admin, _customer, _merchant, _usdc) = setup_env(); + let (_env, client, _contract_id, _admin, _customer, _merchant, _usdc, _xlm) = setup_env(); assert_eq!(client.get_version(), 1); } #[test] #[should_panic(expected = "Not admin")] fn test_upgrade_unauthorized() { - let (env, client, _contract_id, _admin, _customer, _merchant, _usdc) = setup_env(); + let (env, client, _contract_id, _admin, _customer, _merchant, _usdc, _xlm) = setup_env(); let random = Address::generate(&env); let dummy_hash = BytesN::from_array(&env, &[0; 32]); @@ -885,7 +887,7 @@ fn test_upgrade_unauthorized() { #[test] fn test_upgrade_version_increment_stub() { - let (env, client, _contract_id, admin, _customer, _merchant, _usdc) = setup_env(); + let (env, client, _contract_id, admin, _customer, _merchant, _usdc, _xlm) = setup_env(); assert_eq!(client.get_version(), 1); @@ -919,6 +921,7 @@ fn test_partial_release_blocked_for_unverified_merchant() { &merchant, &200_000_000i128, &DEFAULT_PAYMENT_TTL, + &AssetType::Usdc, ); // Partial release must also be blocked. @@ -928,7 +931,7 @@ fn test_partial_release_blocked_for_unverified_merchant() { #[test] fn test_release_allowed_when_no_registry_configured() { // Without a registry, KYC is not enforced. - let (env, client, contract_id, admin, customer, merchant, usdc) = setup_env(); + let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); let payment_id = make_id(&env, 53); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); From 88ff54a3c69fc632c5c988e8b2d6d72f145b5711 Mon Sep 17 00:00:00 2001 From: Jeremiah Peters Date: Wed, 29 Apr 2026 12:18:30 +0100 Subject: [PATCH 2/2] [Soroban] Token balance verification before payment confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add queryContractBalance(paymentId) to StellarService: read-only simulateTransaction call to get_balance on the escrow contract, returns i128 as bigint, null on RPC failure - Add verifyDepositBalance() to SorobanMonitorService: called on every deposit event before status is set to CONFIRMED; compares on-chain stroops against payment.amountUsdc * 10_000_000 - Short-paid deposits are rejected (not confirmed, not auto-settled), discrepancy logged to Sentry via captureException with full context, and an AdminAlert is raised with dedupeKey soroban-monitor.short-paid - RPC unavailability (null balance) is treated as non-blocking — a transient failure does not permanently block confirmation - Balance check is deposit-only; release/refund/expired events are unaffected - Add soroban-monitor.service.spec.ts: unit tests covering full-pay, over-pay, short-pay, zero-balance, RPC-unavailable, non-deposit events, and event deduplication --- .../stellar/soroban-monitor.service.spec.ts | 233 ++++++++++++++++++ .../src/stellar/soroban-monitor.service.ts | 89 +++++++ backend/src/stellar/stellar.service.ts | 55 +++++ 3 files changed, 377 insertions(+) create mode 100644 backend/src/stellar/soroban-monitor.service.spec.ts diff --git a/backend/src/stellar/soroban-monitor.service.spec.ts b/backend/src/stellar/soroban-monitor.service.spec.ts new file mode 100644 index 00000000..ac6c15f2 --- /dev/null +++ b/backend/src/stellar/soroban-monitor.service.spec.ts @@ -0,0 +1,233 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import * as Sentry from '@sentry/nestjs'; +import { SorobanMonitorService } from './soroban-monitor.service'; +import { StellarService } from './stellar.service'; +import { Payment, PaymentStatus } from '../payments/entities/payment.entity'; +import { AdminAlertService } from '../alerts/admin-alert.service'; +import { AdminAlertType } from '../alerts/admin-alert.entity'; + +jest.mock('@sentry/nestjs', () => ({ + captureException: jest.fn(), +})); + +const mockPaymentsRepo = () => ({ + find: jest.fn(), + save: jest.fn(), +}); + +const mockAdminAlerts = () => ({ + raise: jest.fn(), +}); + +const mockStellarService = () => ({ + queryContractBalance: jest.fn(), +}); + +const mockConfigService = () => ({ + get: jest.fn((key: string, fallback?: unknown) => { + if (key === 'SOROBAN_RPC_URL') return 'https://soroban-testnet.stellar.org'; + if (key === 'SOROBAN_ESCROW_CONTRACT_ID') return 'CONTRACT_ID_TEST'; + return fallback; + }), +}); + +function makePayment(overrides: Partial = {}): Payment { + return { + id: 'payment-uuid-1', + reference: 'PAY-001', + merchantId: 'merchant-1', + amountUsd: 25, + amountUsdc: 25, + amountXlm: null, + status: PaymentStatus.PENDING, + stellarMemo: 'MEMO1', + metadata: { escrowPaymentId: 'escrow-id-abc' }, + ...overrides, + } as Payment; +} + +describe('SorobanMonitorService – balance verification', () => { + let service: SorobanMonitorService; + let paymentsRepo: jest.Mocked>; + let adminAlerts: jest.Mocked; + let stellar: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SorobanMonitorService, + { provide: getRepositoryToken(Payment), useFactory: mockPaymentsRepo }, + { provide: AdminAlertService, useFactory: mockAdminAlerts }, + { provide: StellarService, useFactory: mockStellarService }, + { provide: ConfigService, useFactory: mockConfigService }, + ], + }).compile(); + + service = module.get(SorobanMonitorService); + paymentsRepo = module.get(getRepositoryToken(Payment)); + adminAlerts = module.get(AdminAlertService); + stellar = module.get(StellarService); + + jest.clearAllMocks(); + }); + + // ── Helper: drive pollEscrowEvents with a synthetic deposit event ────────── + + function buildDepositEvent(escrowPaymentId: string, eventId = 'evt-1') { + return { + type: 'contract', + ledger: 100, + ledgerClosedAt: '2024-01-01T00:00:00Z', + contractId: 'CONTRACT_ID_TEST', + id: eventId, + pagingToken: eventId, + topic: ['ESCROW', 'deposit'], + value: { payment_id: escrowPaymentId, amount: '250000000' }, + }; + } + + async function driveWithEvent(payment: Payment, event: ReturnType) { + paymentsRepo.find.mockResolvedValue([payment]); + + // Patch getEvents to return our synthetic event + (service as any).escrowContractId = 'CONTRACT_ID_TEST'; + jest + .spyOn(service as any, 'getEvents') + .mockResolvedValue({ events: [event], latestLedger: 101 }); + + await service.pollEscrowEvents(); + } + + // ── Tests ────────────────────────────────────────────────────────────────── + + it('confirms payment when on-chain balance equals expected amount', async () => { + const payment = makePayment({ amountUsdc: 25 }); // 25 USDC = 250_000_000 stroops + stellar.queryContractBalance.mockResolvedValue(BigInt(250_000_000)); + + await driveWithEvent(payment, buildDepositEvent('escrow-id-abc')); + + expect(paymentsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: PaymentStatus.CONFIRMED }), + ); + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(adminAlerts.raise).not.toHaveBeenCalledWith( + expect.objectContaining({ dedupeKey: expect.stringContaining('short-paid') }), + ); + }); + + it('confirms payment when on-chain balance exceeds expected amount (over-paid)', async () => { + const payment = makePayment({ amountUsdc: 25 }); + stellar.queryContractBalance.mockResolvedValue(BigInt(300_000_000)); // more than expected + + await driveWithEvent(payment, buildDepositEvent('escrow-id-abc')); + + expect(paymentsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: PaymentStatus.CONFIRMED }), + ); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('rejects confirmation and reports to Sentry when balance is less than expected', async () => { + const payment = makePayment({ amountUsdc: 25 }); // expects 250_000_000 stroops + stellar.queryContractBalance.mockResolvedValue(BigInt(100_000_000)); // short-paid + + await driveWithEvent(payment, buildDepositEvent('escrow-id-abc')); + + // Payment must NOT be confirmed + expect(paymentsRepo.save).not.toHaveBeenCalled(); + + // Sentry must be notified + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + const sentryError: Error = (Sentry.captureException as jest.Mock).mock.calls[0][0]; + expect(sentryError.message).toContain('Short-paid deposit detected'); + expect(sentryError.message).toContain('PAY-001'); + expect(sentryError.message).toContain('expected=250000000'); + expect(sentryError.message).toContain('on-chain=100000000'); + expect(sentryError.message).toContain('shortfall=150000000'); + + // Admin alert must be raised + expect(adminAlerts.raise).toHaveBeenCalledWith( + expect.objectContaining({ + type: AdminAlertType.STELLAR_MONITOR, + dedupeKey: `soroban-monitor.short-paid:${payment.id}`, + }), + ); + }); + + it('rejects confirmation when balance is zero (no funds received)', async () => { + const payment = makePayment({ amountUsdc: 10 }); + stellar.queryContractBalance.mockResolvedValue(BigInt(0)); + + await driveWithEvent(payment, buildDepositEvent('escrow-id-abc')); + + expect(paymentsRepo.save).not.toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + }); + + it('proceeds without verification when queryContractBalance returns null (RPC unavailable)', async () => { + const payment = makePayment({ amountUsdc: 25 }); + stellar.queryContractBalance.mockResolvedValue(null); + + await driveWithEvent(payment, buildDepositEvent('escrow-id-abc')); + + // Should still confirm — transient RPC failure must not block confirmation + expect(paymentsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: PaymentStatus.CONFIRMED }), + ); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('does not run balance check for non-deposit events (release)', async () => { + const payment = makePayment({ status: PaymentStatus.CONFIRMED, amountUsdc: 25 }); + stellar.queryContractBalance.mockResolvedValue(BigInt(0)); // would fail if called + + paymentsRepo.find.mockResolvedValue([payment]); + (service as any).escrowContractId = 'CONTRACT_ID_TEST'; + jest.spyOn(service as any, 'getEvents').mockResolvedValue({ + events: [ + { + type: 'contract', + ledger: 100, + ledgerClosedAt: '2024-01-01T00:00:00Z', + contractId: 'CONTRACT_ID_TEST', + id: 'evt-release-1', + pagingToken: 'evt-release-1', + topic: ['ESCROW', 'release'], + value: { payment_id: 'escrow-id-abc', amount: '250000000' }, + }, + ], + latestLedger: 101, + }); + + await service.pollEscrowEvents(); + + // Balance query must not be called for release events + expect(stellar.queryContractBalance).not.toHaveBeenCalled(); + expect(paymentsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: PaymentStatus.SETTLED }), + ); + }); + + it('deduplicates events — does not process the same event twice', async () => { + const payment = makePayment({ amountUsdc: 25 }); + stellar.queryContractBalance.mockResolvedValue(BigInt(250_000_000)); + + const event = buildDepositEvent('escrow-id-abc', 'evt-dup-1'); + + paymentsRepo.find.mockResolvedValue([payment]); + (service as any).escrowContractId = 'CONTRACT_ID_TEST'; + const getEventsSpy = jest + .spyOn(service as any, 'getEvents') + .mockResolvedValue({ events: [event], latestLedger: 101 }); + + await service.pollEscrowEvents(); + await service.pollEscrowEvents(); + + // save called only once despite two polls + expect(paymentsRepo.save).toHaveBeenCalledTimes(1); + expect(getEventsSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/stellar/soroban-monitor.service.ts b/backend/src/stellar/soroban-monitor.service.ts index 56caa2c4..880d77c5 100644 --- a/backend/src/stellar/soroban-monitor.service.ts +++ b/backend/src/stellar/soroban-monitor.service.ts @@ -2,9 +2,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import * as Sentry from '@sentry/nestjs'; import { Payment, PaymentStatus } from '../payments/entities/payment.entity'; import { AdminAlertService } from '../alerts/admin-alert.service'; import { AdminAlertType } from '../alerts/admin-alert.entity'; +import { StellarService } from './stellar.service'; interface SorobanEvent { type: string; @@ -47,6 +49,7 @@ export class SorobanMonitorService { private readonly paymentsRepo: Repository, private readonly config: ConfigService, private readonly adminAlerts: AdminAlertService, + private readonly stellar: StellarService, ) { this.rpcUrl = this.config.get( 'SOROBAN_RPC_URL', @@ -120,6 +123,15 @@ export class SorobanMonitorService { const matched = payments.find((payment) => this.matchesEscrowPaymentId(payment, paymentId)); if (!matched) return; + // ── Balance verification on deposit ────────────────────────────────────── + // Before confirming a deposit event, query the escrow contract to verify + // the on-chain balance matches the expected payment amount. Short-paid + // deposits are flagged and skipped — they must not be auto-settled. + if (topic === 'deposit') { + const shortPaid = await this.verifyDepositBalance(matched, paymentId, event.id); + if (shortPaid) return; + } + const nextStatus = this.mapTopicToStatus(topic); if (!nextStatus || matched.status === nextStatus) return; @@ -132,6 +144,83 @@ export class SorobanMonitorService { ); } + /** + * Query the escrow contract's get_balance view function and compare the + * on-chain balance against the payment's expected USDC amount. + * + * Returns true (short-paid / mismatch) when the balance check fails and the + * deposit should NOT be confirmed. Returns false when the balance is + * sufficient and processing can continue. + */ + private async verifyDepositBalance( + payment: Payment, + escrowPaymentId: string, + eventId: string, + ): Promise { + const onChainBalance = await this.stellar.queryContractBalance(escrowPaymentId); + + if (onChainBalance === null) { + // Could not query — log a warning but allow processing to continue so + // a transient RPC failure does not permanently block confirmation. + this.logger.warn( + `Balance query unavailable for payment ${payment.reference} (escrowId=${escrowPaymentId}); proceeding without verification`, + ); + return false; + } + + // Expected amount in stroops: amountUsdc is stored as a decimal number of + // USDC units; 1 USDC = 10_000_000 stroops (7 decimal places on Stellar). + const expectedStroops = BigInt( + Math.round(Number(payment.amountUsdc ?? 0) * 10_000_000), + ); + + if (onChainBalance < expectedStroops) { + const discrepancy = expectedStroops - onChainBalance; + const message = + `Short-paid deposit detected for payment ${payment.reference} ` + + `(escrowId=${escrowPaymentId}): ` + + `expected=${expectedStroops} stroops, ` + + `on-chain=${onChainBalance} stroops, ` + + `shortfall=${discrepancy} stroops`; + + this.logger.warn(message); + + Sentry.captureException(new Error(message), { + tags: { + component: 'soroban-monitor', + event: 'short_paid_deposit', + }, + extra: { + paymentId: payment.id, + reference: payment.reference, + escrowPaymentId, + eventId, + expectedStroops: expectedStroops.toString(), + onChainBalance: onChainBalance.toString(), + discrepancyStroops: discrepancy.toString(), + }, + }); + + await this.adminAlerts.raise({ + type: AdminAlertType.STELLAR_MONITOR, + dedupeKey: `soroban-monitor.short-paid:${payment.id}`, + message, + metadata: { + paymentId: payment.id, + reference: payment.reference, + escrowPaymentId, + expectedStroops: expectedStroops.toString(), + onChainBalance: onChainBalance.toString(), + }, + thresholdValue: 1, + }); + + return true; // short-paid — do not confirm + } + + return false; // balance OK + } + private extractTopic(topics: string[]): EscrowEventTopic | null { const first = topics?.[1] ?? topics?.[0]; if (typeof first !== 'string') return null; diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts index b9e87a29..4d47e3c0 100644 --- a/backend/src/stellar/stellar.service.ts +++ b/backend/src/stellar/stellar.service.ts @@ -267,6 +267,61 @@ export class StellarService implements OnModuleInit { } } + /** + * Read-only query: calls get_balance(payment_id) on the escrow contract via + * simulateTransaction (no signing, no fee). Returns the on-chain remaining + * balance in stroops (i128 represented as bigint). + * + * Returns null when the contract ID is not configured or the simulation fails. + */ + async queryContractBalance(paymentId: string): Promise { + if (!this.sorobanContractId) return null; + + try { + const contract = new StellarSdk.Contract(this.sorobanContractId); + // Build a minimal transaction — source account is not required to exist + // for simulation-only calls; we use a well-known testnet account as a + // placeholder when no keypair is configured. + const sourcePublicKey = this.keypair + ? this.keypair.publicKey() + : 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; + + const source = new StellarSdk.Account(sourcePublicKey, '0'); + const tx = new StellarSdk.TransactionBuilder(source, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'get_balance', + StellarSdk.nativeToScVal(paymentId, { type: 'bytes' }), + ), + ) + .setTimeout(30) + .build(); + + const simulated = await this.sorobanRpcServer.simulateTransaction(tx); + if (StellarSdk.rpc.Api.isSimulationError(simulated)) { + this.logger.warn( + `get_balance simulation error for payment ${paymentId}: ${simulated.error}`, + ); + return null; + } + + const result = (simulated as StellarSdk.rpc.Api.SimulateTransactionSuccessResponse) + .result?.retval; + if (!result) return null; + + // The contract returns i128; scValToNative converts it to bigint. + return BigInt(StellarSdk.scValToNative(result) as string | number | bigint); + } catch (err) { + this.logger.warn( + `queryContractBalance failed for payment ${paymentId}: ${(err as Error).message}`, + ); + return null; + } + } + private extractSorobanErrorCode(error: unknown): string { const serialized = typeof error === 'string' ? error : JSON.stringify(error ?? {});