From 683095b91ccfde9a622e6627d0a1dbd920e77798 Mon Sep 17 00:00:00 2001 From: Lovesmile Small Date: Thu, 20 Aug 2026 16:16:28 +0000 Subject: [PATCH] fix(onchain): remove SorobanOnchainAdapter stub that fabricated success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SorobanOnchainAdapter posted a hand-rolled {contractId, method, args} JSON blob to simulateTransaction/sendTransaction — not a Soroban envelope — and returned status 'success' with an empty transactionHash for every mutation, silently corrupting the audit trail. Delete it and its stale coverage-baseline entry, keeping SorobanAdapter as the single canonical adapter, and add a regression spec proving the adapter submits real TransactionBuilder-built envelopes and only reports success with a confirmed non-empty hash. --- .../src/onchain/soroban-onchain.adapter.ts | 390 ------------------ .../src/onchain/soroban.adapter.spec.ts | 259 ++++++++++++ app/backend/test/coverage-baseline.json | 1 - 3 files changed, 259 insertions(+), 391 deletions(-) delete mode 100644 app/backend/src/onchain/soroban-onchain.adapter.ts create mode 100644 app/backend/src/onchain/soroban.adapter.spec.ts diff --git a/app/backend/src/onchain/soroban-onchain.adapter.ts b/app/backend/src/onchain/soroban-onchain.adapter.ts deleted file mode 100644 index 2fc3250a..00000000 --- a/app/backend/src/onchain/soroban-onchain.adapter.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; -import { - OnchainAdapter, - ONCHAIN_ADAPTER_TOKEN, - AidPackage, - InitEscrowParams, - InitEscrowResult, - CreateAidPackageParams, - CreateAidPackageResult, - BatchCreateAidPackagesParams, - BatchCreateAidPackagesResult, - ClaimAidPackageParams, - ClaimAidPackageResult, - DisburseAidPackageParams, - DisburseAidPackageResult, - GetAidPackageParams, - GetAidPackageResult, - GetAidPackageCountParams, - GetAidPackageCountResult, - GetTokenBalanceParams, - GetTokenBalanceResult, - CreateClaimParams, - CreateClaimResult, - DisburseParams, - DisburseResult, - ContractMetadata, - PauseState, - FeeConfig, - PackageSummary, - GetTransactionStatusParams, - GetTransactionStatusResult, - TxStatus, -} from './onchain.adapter'; -import { toContractString } from './utils/contract-value'; - -/** Calls the Soroban RPC endpoint and returns the result value. */ -async function rpcCall( - http: HttpService, - rpcUrl: string, - method: string, - params: unknown, -): Promise { - const body = { jsonrpc: '2.0', id: 1, method, params }; - const res = await firstValueFrom(http.post(rpcUrl, body)); - if (res.data.error) { - throw new Error(JSON.stringify(res.data.error)); - } - return res.data.result; -} - -@Injectable() -export class SorobanOnchainAdapter implements OnchainAdapter { - private readonly logger = new Logger(SorobanOnchainAdapter.name); - private readonly rpcUrl: string; - private readonly contractId: string; - private readonly secretKey: string; - private readonly networkPassphrase: string; - - constructor( - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - this.rpcUrl = config.getOrThrow('SOROBAN_RPC_URL'); - this.contractId = config.getOrThrow('SOROBAN_CONTRACT_ID'); - this.secretKey = config.getOrThrow('SOROBAN_SECRET_KEY'); - const network = config.get('STELLAR_NETWORK', 'testnet'); - this.networkPassphrase = - network === 'mainnet' - ? 'Public Global Stellar Network ; September 2015' - : 'Test SDF Network ; September 2015'; - } - - private async invokeContract( - method: string, - args: unknown[], - ): Promise { - const sim = await rpcCall(this.http, this.rpcUrl, 'simulateTransaction', { - transaction: JSON.stringify({ - contractId: this.contractId, - method, - args, - }), - }); - if (sim && typeof sim === 'object' && 'error' in sim) { - const error = (sim as Record).error; - throw new Error('Simulation error: ' + JSON.stringify(error)); - } - const result = await rpcCall(this.http, this.rpcUrl, 'sendTransaction', { - transaction: JSON.stringify({ - contractId: this.contractId, - method, - args, - networkPassphrase: this.networkPassphrase, - secret: this.secretKey, - }), - }); - return result && typeof result === 'object' && 'returnValue' in result - ? (result as Record).returnValue - : null; - } - - async initEscrow(params: InitEscrowParams): Promise { - this.logger.log('initEscrow admin=' + params.adminAddress); - await this.invokeContract('initialize', [params.adminAddress]); - return { - escrowAddress: this.contractId, - transactionHash: '', - timestamp: new Date(), - status: 'success', - }; - } - - async createAidPackage( - params: CreateAidPackageParams, - ): Promise { - this.logger.log('createAidPackage id=' + params.packageId); - await this.invokeContract('create_package', [ - params.operatorAddress, - params.packageId, - params.recipientAddress, - params.amount, - params.tokenAddress, - params.expiresAt, - ]); - return { - packageId: params.packageId, - transactionHash: '', - timestamp: new Date(), - status: 'success', - }; - } - - async batchCreateAidPackages( - params: BatchCreateAidPackagesParams, - ): Promise { - const packageIds: string[] = []; - for (let i = 0; i < params.recipientAddresses.length; i++) { - const id = String(Date.now()) + '-' + String(i); - await this.createAidPackage({ - operatorAddress: params.operatorAddress, - packageId: id, - recipientAddress: params.recipientAddresses[i], - amount: params.amounts[i], - tokenAddress: params.tokenAddress, - expiresAt: Math.floor(Date.now() / 1000) + params.expiresIn, - }); - packageIds.push(id); - } - return { - packageIds, - transactionHash: '', - timestamp: new Date(), - status: 'success', - }; - } - - async claimAidPackage( - params: ClaimAidPackageParams, - ): Promise { - await this.invokeContract('claim_package', [ - params.packageId, - params.recipientAddress, - ]); - return { - packageId: params.packageId, - transactionHash: '', - timestamp: new Date(), - status: 'success', - amountClaimed: '0', - }; - } - - async disburseAidPackage( - params: DisburseAidPackageParams, - ): Promise { - await this.invokeContract('disburse_package', [ - params.packageId, - params.operatorAddress, - ]); - return { - packageId: params.packageId, - transactionHash: '', - timestamp: new Date(), - status: 'success', - amountDisbursed: '0', - }; - } - - /** - * Validates a raw status value from the RPC response against the known - * AidPackage status union. Falls back to 'Created' for any unexpected - * shape instead of trusting an unchecked cast at the contract boundary. - */ - private parsePackageStatus(status: unknown): AidPackage['status'] { - const known: AidPackage['status'][] = [ - 'Created', - 'Claimed', - 'Expired', - 'Cancelled', - 'Refunded', - ]; - if (typeof status === 'string' && (known as string[]).includes(status)) { - return status as AidPackage['status']; - } - return 'Created'; - } - - async getAidPackage( - params: GetAidPackageParams, - ): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: this.contractId, - key: params.packageId, - }); - const pkg = result as Record | null; - return { - package: { - id: params.packageId, - recipient: toContractString(pkg?.recipient), - amount: toContractString(pkg?.amount, '0'), - token: toContractString(pkg?.token), - status: this.parsePackageStatus(pkg?.status), - createdAt: Number(pkg?.created_at ?? 0), - expiresAt: Number(pkg?.expires_at ?? 0), - }, - timestamp: new Date(), - }; - } - - async getAidPackageCount( - params: GetAidPackageCountParams, - ): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: this.contractId, - key: 'aggregates_' + params.token, - }); - const agg = result as Record | null; - return { - aggregates: { - totalCommitted: toContractString(agg?.total_committed, '0'), - totalClaimed: toContractString(agg?.total_claimed, '0'), - totalExpiredCancelled: toContractString( - agg?.total_expired_cancelled, - '0', - ), - }, - timestamp: new Date(), - }; - } - - async getTokenBalance( - params: GetTokenBalanceParams, - ): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: params.tokenAddress, - key: params.accountAddress, - }); - return { - tokenAddress: params.tokenAddress, - accountAddress: params.accountAddress, - balance: toContractString(result, '0'), - timestamp: new Date(), - }; - } - - async getContractMetadata(): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: this.contractId, - key: 'metadata', - }); - const data = result as Record | null; - return { - version: toContractString(data?.version, '1.0.0'), - name: toContractString(data?.name, 'Soroban Contract'), - timestamp: new Date(), - }; - } - - async getPauseState(): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: this.contractId, - key: 'paused', - }); - return { - isPaused: Boolean(result), - timestamp: new Date(), - }; - } - - async getFeeConfig(): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: this.contractId, - key: 'fee_config', - }); - const data = result as Record | null; - return { - feePercentage: toContractString(data?.fee_percentage, '0'), - maxFee: toContractString(data?.max_fee, '0'), - timestamp: new Date(), - }; - } - - async getPackageSummary(packageId: string): Promise { - const result = await rpcCall(this.http, this.rpcUrl, 'getContractData', { - contractId: this.contractId, - key: 'summary_' + packageId, - }); - const data = result as Record | null; - return { - packageId, - totalAmount: toContractString(data?.total_amount, '0'), - claimedAmount: toContractString(data?.claimed_amount, '0'), - status: toContractString(data?.status, 'Active'), - timestamp: new Date(), - }; - } - - async createClaim(params: CreateClaimParams): Promise { - const result = await this.createAidPackage({ - operatorAddress: this.secretKey, - packageId: params.claimId, - recipientAddress: params.recipientAddress, - amount: params.amount, - tokenAddress: params.tokenAddress, - expiresAt: params.expiresAt ?? Math.floor(Date.now() / 1000) + 86400 * 30, - }); - return { - packageId: result.packageId, - transactionHash: result.transactionHash, - timestamp: result.timestamp, - status: result.status, - }; - } - - async disburse(params: DisburseParams): Promise { - const result = await this.disburseAidPackage({ - packageId: params.packageId, - operatorAddress: params.recipientAddress ?? this.secretKey, - }); - return { - transactionHash: result.transactionHash, - timestamp: result.timestamp, - status: result.status, - amountDisbursed: result.amountDisbursed, - }; - } - - async getTransactionStatus( - params: GetTransactionStatusParams, - ): Promise { - const hash = params.hash.toUpperCase(); - try { - const result = await rpcCall(this.http, this.rpcUrl, 'getTransaction', { - hash, - }); - const r = result as Record | null; - let status: TxStatus; - switch (r?.status) { - case 'SUCCESS': - status = 'succeeded'; - break; - case 'FAILED': - status = 'failed'; - break; - case 'NOT_FOUND': - status = 'pending'; - break; - default: - status = 'unknown'; - } - return { - hash, - status, - timestamp: new Date(), - ledger: typeof r?.ledger === 'number' ? r.ledger : undefined, - errorMessage: - status === 'failed' - ? toContractString(r?.resultXdr, 'Transaction failed') - : undefined, - }; - } catch { - return { hash, status: 'unknown', timestamp: new Date() }; - } - } -} - -export { ONCHAIN_ADAPTER_TOKEN }; diff --git a/app/backend/src/onchain/soroban.adapter.spec.ts b/app/backend/src/onchain/soroban.adapter.spec.ts new file mode 100644 index 00000000..70cc7623 --- /dev/null +++ b/app/backend/src/onchain/soroban.adapter.spec.ts @@ -0,0 +1,259 @@ +import { ConfigService } from '@nestjs/config'; +import { SorobanAdapter } from './soroban.adapter'; + +/** + * Regression tests for issue #426. + * + * `SorobanOnchainAdapter` (deleted) fabricated success by posting a hand-rolled + * `{ contractId, method, args }` JSON blob to `simulateTransaction` / + * `sendTransaction` — a payload that is not a Soroban envelope — and returned + * `status: 'success'` with an empty `transactionHash` for every call. These + * tests lock the canonical `SorobanAdapter` to the opposite contract: + * + * 1. what reaches `simulateTransaction`/`sendTransaction` is a real + * `TransactionBuilder`-built transaction (an object with `toEnvelope`), + * never a JSON blob; and + * 2. a mutation only reports `status: 'success'` with the non-empty hash of + * an actually sent, confirmed transaction — a failed simulation or + * rejected submission rejects instead of fabricating success. + */ + +const mockServer = { + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), +}; + +jest.mock('@stellar/stellar-sdk', () => { + const txLike = () => ({ + toEnvelope: jest.fn(() => 'ENVELOPE-XDR'), + toXDR: jest.fn(() => 'TRANSACTION-XDR'), + sign: jest.fn(), + hash: jest.fn(() => Buffer.from('abc')), + }); + + return { + rpc: { + Server: jest.fn().mockImplementation(() => mockServer), + Api: { + isSimulationError: jest.fn((sim: unknown) => + Boolean(sim && typeof sim === 'object' && 'error' in sim), + ), + isSimulationSuccess: jest.fn((sim: unknown) => + Boolean(sim && typeof sim === 'object' && 'result' in sim), + ), + GetTransactionStatus: { + NOT_FOUND: 'NOT_FOUND', + FAILED: 'FAILED', + SUCCESS: 'SUCCESS', + }, + }, + assembleTransaction: jest.fn( + (tx: unknown) => + ({ + build: () => tx, + }) as unknown, + ), + }, + Contract: jest.fn().mockImplementation(() => ({ + call: jest.fn().mockReturnValue({ _op: 'contract-call' }), + })), + nativeToScVal: jest.fn(v => ({ _native: v })), + scValToNative: jest.fn(v => + v && typeof v === 'object' && '_native' in v ? v._native : v, + ), + TransactionBuilder: jest.fn().mockImplementation(() => ({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn(txLike), + })), + Keypair: { + fromSecret: jest.fn().mockReturnValue({ + publicKey: () => + 'GBXGQJWVLWOYHFLVTKWV5FGHA3JYYV3A7JQKNO6TCTSVL4K3JDLDZBPK', + sign: jest.fn(), + }), + }, + BASE_FEE: '100', + xdr: {}, + }; +}); + +describe('SorobanAdapter (issue #426 regression)', () => { + const CONTRACT_ID = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + const SECRET = 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const TOKEN = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4'; + const RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + + function buildAdapter(): SorobanAdapter { + const config = { + get: jest.fn((key: string, fallback?: unknown) => { + switch (key) { + case 'AID_ESCROW_CONTRACT_ID': + return CONTRACT_ID; + case 'SOROBAN_ADMIN_SECRET_KEY': + return SECRET; + case 'STELLAR_RPC_URL': + return 'https://soroban-testnet.stellar.org'; + case 'STELLAR_NETWORK_PASSPHRASE': + return 'Test SDF Network ; September 2015'; + case 'SOROBAN_NETWORK': + return 'testnet'; + default: + return fallback; + } + }), + } as unknown as ConfigService; + + return new SorobanAdapter(config); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockServer.getAccount.mockResolvedValue({ + id: 'GBXGQJWVLWOYHFLVTKWV5FGHA3JYYV3A7JQKNO6TCTSVL4K3JDLDZBPK', + sequence: '1234567890', + }); + mockServer.simulateTransaction.mockResolvedValue({ + result: { retval: { _native: 'ok' } }, + minResourceFee: '100', + }); + mockServer.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + }); + mockServer.getTransaction.mockResolvedValue({ + status: 'SUCCESS', + returnValue: { _native: 'ok' }, + }); + }); + + it('submits a real TransactionBuilder-built envelope, not a JSON blob', async () => { + const adapter = buildAdapter(); + const result = await adapter.createAidPackage({ + operatorAddress: RECIPIENT, + packageId: '1', + recipientAddress: RECIPIENT, + amount: '100', + tokenAddress: TOKEN, + expiresAt: 1767225600, + }); + + // The transaction passed to the RPC boundary must be an envelope object, + // never a stringified `{contractId, method, args}` blob. + const simulated = mockServer.simulateTransaction.mock.calls[0][0]; + expect(simulated).toBeDefined(); + expect(typeof simulated).toBe('object'); + expect(typeof (simulated as { toEnvelope?: unknown }).toEnvelope).toBe( + 'function', + ); + expect(typeof (simulated as { toXDR?: unknown }).toXDR).toBe('function'); + expect(JSON.stringify(simulated)).not.toContain('contractId'); + expect(JSON.stringify(simulated)).not.toContain('"method"'); + expect(JSON.stringify(simulated)).not.toContain('"args"'); + + // Same for the submitted (signed, prepared) transaction. + const sent = mockServer.sendTransaction.mock.calls[0][0]; + expect(sent).toBeDefined(); + expect(typeof (sent as { toEnvelope?: unknown }).toEnvelope).toBe( + 'function', + ); + + // Success is only reported together with the real confirmed hash. + expect(result.status).toBe('success'); + expect(result.transactionHash).toMatch(/^[A-F0-9]{64}$/); + expect(result.transactionHash).not.toBe(''); + }); + + it('rejects when simulation fails instead of fabricating success', async () => { + mockServer.simulateTransaction.mockResolvedValue({ + error: 'HostError: contract rejected', + }); + + const adapter = buildAdapter(); + await expect( + adapter.createAidPackage({ + operatorAddress: RECIPIENT, + packageId: '1', + recipientAddress: RECIPIENT, + amount: '100', + tokenAddress: TOKEN, + expiresAt: 1767225600, + }), + ).rejects.toThrow('Contract simulation error'); + }); + + it('rejects when submission is not PENDING/DUPLICATE', async () => { + mockServer.sendTransaction.mockResolvedValue({ + status: 'ERROR', + errorResultXdr: 'AAAABQ==', + }); + + const adapter = buildAdapter(); + await expect( + adapter.createAidPackage({ + operatorAddress: RECIPIENT, + packageId: '1', + recipientAddress: RECIPIENT, + amount: '100', + tokenAddress: TOKEN, + expiresAt: 1767225600, + }), + ).rejects.toThrow('Transaction submission failed with status: ERROR'); + }); + + it('rejects when the confirmed transaction failed', async () => { + mockServer.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890', + }); + mockServer.getTransaction.mockResolvedValue({ + status: 'FAILED', + resultXdr: 'AAAA', + }); + + const adapter = buildAdapter(); + await expect( + adapter.createAidPackage({ + operatorAddress: RECIPIENT, + packageId: '1', + recipientAddress: RECIPIENT, + amount: '100', + tokenAddress: TOKEN, + expiresAt: 1767225600, + }), + ).rejects.toThrow(); + }); + + it('calls the real contract method name and argument list', async () => { + const adapter = buildAdapter(); + await adapter.createAidPackage({ + operatorAddress: RECIPIENT, + packageId: '42', + recipientAddress: RECIPIENT, + amount: '250', + tokenAddress: TOKEN, + expiresAt: 1767225600, + }); + + // The operation added to the envelope must target `create_package`, the + // contract's real entrypoint (the deleted stub called `create_package` + // with a 6-argument JSON blob; the contract takes 7 typed args). + const { Contract, TransactionBuilder } = jest.requireMock( + '@stellar/stellar-sdk', + ); + + const contractInstance = Contract.mock.results[0].value; + const methodCalls = (contractInstance.call as jest.Mock).mock.calls; + expect(methodCalls).toHaveLength(1); + expect(methodCalls[0][0]).toBe('create_package'); + + // The envelope carries one operation (the contract call), i.e. the + // transaction was actually built, not stubbed as a JSON string. + const builderInstance = TransactionBuilder.mock.results[0].value; + expect(builderInstance.addOperation).toHaveBeenCalledTimes(1); + expect(builderInstance.build).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/backend/test/coverage-baseline.json b/app/backend/test/coverage-baseline.json index 7d34e044..3c1daee6 100644 --- a/app/backend/test/coverage-baseline.json +++ b/app/backend/test/coverage-baseline.json @@ -99,7 +99,6 @@ ["src/onchain/onchain.adapter.ts",100,100,100,100], ["src/onchain/onchain.processor.ts",-33,-37,-3,-33], ["src/onchain/onchain.service.ts",-12,-8,-4,-12], - ["src/onchain/soroban-onchain.adapter.ts",-84,-42,-20,-87], ["src/onchain/soroban.adapter.ts",-197,-100,-44,-201], ["src/onchain/utils/contract-value.ts",-14,-18,-2,-14], ["src/onchain/utils/retry-with-timeout.ts",-14,-7,-4,-16],