From 56f754334bd6d178eebc9bb59fd27381466bd51d Mon Sep 17 00:00:00 2001 From: Sparexonzy95 <85989949+Sparexonzy95@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:40:53 +0100 Subject: [PATCH] Validate Soroban simulation auth before signing --- src/lib/error-handler.ts | 37 ++++ src/lib/soroban.auth.test.ts | 69 ++++++++ src/lib/soroban.ts | 323 ++++++++++++++++++++++++++++++++--- 3 files changed, 401 insertions(+), 28 deletions(-) create mode 100644 src/lib/soroban.auth.test.ts diff --git a/src/lib/error-handler.ts b/src/lib/error-handler.ts index 0e64d1e..372206a 100644 --- a/src/lib/error-handler.ts +++ b/src/lib/error-handler.ts @@ -174,6 +174,30 @@ export class ValidationError extends SmartDropError { } } +/** + * Security-sensitive transaction validation errors. + */ +export class SecurityError extends SmartDropError { + readonly code = "SECURITY_ERROR"; + readonly isTransient = false; + readonly isCritical = true; + + constructor(message: string, originalError?: Error) { + super(message, originalError); + Object.setPrototypeOf(this, SecurityError.prototype); + } + + readonly userMessage = this.message; + + getLogContext() { + return { + ...super.getLogContext(), + errorType: "SecurityError", + isSigningSafetyIssue: true, + }; + } +} + /** * Configuration errors (missing env vars, invalid config). */ @@ -248,6 +272,19 @@ export function normalizeError(error: unknown, context?: string): SmartDropError return new FreighterError("FREIGHTER_UNKNOWN", error.message, error); } + // Security/signing-safety errors + if ( + msg.includes("security") || + msg.includes("signing was blocked") || + msg.includes("authorization entry") || + msg.includes("unexpected auth") || + msg.includes("simulation auth") || + msg.includes("simulated authorization") + ) { + return new SecurityError(error.message, error); + } + + // RPC errors if (msg.includes("timeout") || msg.includes("timed out")) { return new RPCError("RPC_TIMEOUT", error.message, error); diff --git a/src/lib/soroban.auth.test.ts b/src/lib/soroban.auth.test.ts new file mode 100644 index 0000000..9e5f962 --- /dev/null +++ b/src/lib/soroban.auth.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { Address, type xdr } from '@stellar/stellar-sdk'; +import { SecurityError } from './error-handler'; +import { validateSimulationAuth } from './soroban'; + +describe('validateSimulationAuth', () => { + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + + it('throws SecurityError when simulation includes an extra unexpected auth entry', () => { + const unexpectedAuthEntry = {} as xdr.SorobanAuthorizationEntry; + + const simResult = { + result: { + auth: [unexpectedAuthEntry, unexpectedAuthEntry], + }, + }; + + expect(() => + validateSimulationAuth(simResult, [ + { + contractId, + functionName: 'unlock_assets', + }, + ]), + ).toThrow(SecurityError); + }); + + it('throws SecurityError when expected root auth includes nested sub-invocations', () => { + const authEntry = { + credentials: () => ({}), + rootInvocation: () => ({ + function: () => ({ + switch: () => 'contract', + contractFn: () => ({ + contractAddress: () => Address.fromString(contractId).toScAddress(), + functionName: () => 'unlock_assets', + }), + }), + subInvocations: () => [ + { + function: () => ({ + switch: () => 'contract', + contractFn: () => ({ + contractAddress: () => Address.fromString(contractId).toScAddress(), + functionName: () => 'malicious_call', + }), + }), + subInvocations: () => [], + }, + ], + }), + } as xdr.SorobanAuthorizationEntry; + + const simResult = { + result: { + auth: [authEntry], + }, + }; + + expect(() => + validateSimulationAuth(simResult, [ + { + contractId, + functionName: 'unlock_assets', + }, + ]), + ).toThrow(SecurityError); + }); +}); diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index 1e4abf7..5931503 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -6,31 +6,33 @@ import { Contract, Networks, - Operation, TransactionBuilder, BASE_FEE, - Memo, xdr, Address, nativeToScVal, scValToNative, rpc, } from '@stellar/stellar-sdk'; +import { SecurityError } from './error-handler'; import { bigintToDisplayAmount, parsePoolsFromNative, parseUserPositionFromNative, } from './soroban-parsers'; +import type { + AssetInfo, + PoolInfo, + UserPosition, +} from './soroban-parsers'; export type { AssetInfo, PoolInfo, UserPosition } from './soroban-parsers'; // Soroban RPC Configuration const RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org:443'; -const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet'; const NETWORK_PASSPHRASE = process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE || Networks.TESTNET; // Contract Addresses (will be set via environment variables in production) const FACTORY_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ADDRESS || ''; -const DEFAULT_POOL_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_DEFAULT_POOL_CONTRACT_ADDRESS || ''; // Initialize Soroban RPC Server const rpcServer = new rpc.Server(RPC_URL); @@ -105,6 +107,231 @@ export function parseCreditsFromXdrResult(xdrResult: xdr.ScVal): string { } } + + +type FreighterSignTransactionResult = + | string + | { + signedTxXdr?: string; + signerAddress?: string; + error?: unknown; + }; + +interface FreighterWalletApi { + signTransaction: ( + transactionXdr: string, + options: { networkPassphrase: string }, + ) => Promise; +} + +function getSignedTransactionXdr( + result: FreighterSignTransactionResult, +): string { + if (typeof result === 'string') { + return result; + } + + if (result.error) { + throw new Error( + typeof result.error === 'string' + ? result.error + : 'Freighter failed to sign the transaction', + ); + } + + if (result.signedTxXdr) { + return result.signedTxXdr; + } + + throw new Error('Freighter did not return a signed transaction XDR'); +} + +// ── Transaction signing safety ─────────────────────────────────────────────── + +export interface ExpectedSimulationAuth { + contractId: string; + functionName: string; +} + +type SimulationAuthResult = { + result?: { + auth?: xdr.SorobanAuthorizationEntry[] | null; + } | null; +}; + +function normalizeExpectedAuthKey(auth: ExpectedSimulationAuth): string { + return `${auth.contractId.trim().toUpperCase()}:${auth.functionName.trim()}`; +} + +function enumName(value: unknown): string { + if (typeof value === 'string') return value; + + if (value && typeof value === 'object') { + const enumLike = value as { name?: unknown; toString?: () => string }; + + if (typeof enumLike.name === 'string') return enumLike.name; + if (typeof enumLike.name === 'function') { + const name = (enumLike.name as () => unknown)(); + if (typeof name === 'string') return name; + } + if (typeof enumLike.toString === 'function') return enumLike.toString(); + } + + return String(value); +} + +function scSymbolToString(value: unknown): string { + if (typeof value === 'string') return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + if (value && typeof value === 'object' && 'toString' in value) { + return String((value as { toString: () => string }).toString()); + } + return String(value); +} + +function decodeAuthEntryContractFunction( + entry: xdr.SorobanAuthorizationEntry, +): ExpectedSimulationAuth { + try { + const authEntry = entry as unknown as { + credentials?: () => unknown; + rootInvocation?: () => unknown; + }; + + // Decode/access credentials as part of validating the full authorization entry shape. + // The target contract/function is carried by rootInvocation.contractFn. + if (typeof authEntry.credentials !== 'function') { + throw new Error('Authorization entry is missing credentials'); + } + authEntry.credentials(); + + if (typeof authEntry.rootInvocation !== 'function') { + throw new Error('Authorization entry is missing root invocation'); + } + + const invocation = authEntry.rootInvocation() as { + function?: () => unknown; + subInvocations?: () => unknown; + }; + + if (!invocation || typeof invocation.function !== 'function') { + throw new Error('Authorization entry root invocation is malformed'); + } + + const authorizedFunction = invocation.function() as { + switch?: () => unknown; + contractFn?: () => unknown; + }; + + const functionType = + typeof authorizedFunction.switch === 'function' + ? enumName(authorizedFunction.switch()) + : ''; + + if (!functionType.toLowerCase().includes('contract')) { + throw new Error(`Unexpected authorization function type: ${functionType}`); + } + + if (typeof authorizedFunction.contractFn !== 'function') { + throw new Error('Authorization entry does not contain a contract function'); + } + + const contractFn = authorizedFunction.contractFn() as { + contractAddress?: () => xdr.ScAddress; + functionName?: () => unknown; + }; + + if ( + !contractFn || + typeof contractFn.contractAddress !== 'function' || + typeof contractFn.functionName !== 'function' + ) { + throw new Error('Authorization contract function is malformed'); + } + + const decoded = { + contractId: Address.fromScAddress(contractFn.contractAddress()).toString(), + functionName: scSymbolToString(contractFn.functionName()), + }; + assertNoUnexpectedSubInvocations(invocation); + + return decoded; + } catch (error) { + if (error instanceof SecurityError) { + throw error; + } + + throw new SecurityError( + 'Transaction signing was blocked because SmartDrop could not verify the simulated authorization request.', + error instanceof Error ? error : undefined, + ); + } +} + +function assertNoUnexpectedSubInvocations(invocation: { + subInvocations?: () => unknown; +}): void { + if (typeof invocation.subInvocations !== 'function') { + throw new SecurityError( + 'Transaction signing was blocked because SmartDrop could not verify nested authorization requests.', + ); + } + + const subInvocations = invocation.subInvocations(); + if (!Array.isArray(subInvocations)) { + throw new SecurityError( + 'Transaction signing was blocked because SmartDrop could not verify nested authorization requests.', + ); + } + + if (subInvocations.length > 0) { + throw new SecurityError( + 'Transaction signing was blocked because the simulation returned nested authorization requests that SmartDrop did not expect.', + ); + } +} + +export function validateSimulationAuth( + simResult: SimulationAuthResult, + expected: ExpectedSimulationAuth[], +): void { + const authEntries = simResult.result?.auth; + + if (!Array.isArray(authEntries)) { + throw new SecurityError( + 'Transaction signing was blocked because the simulation did not return authorization entries.', + ); + } + + if (authEntries.length !== expected.length) { + throw new SecurityError( + `Transaction signing was blocked because the simulation returned ${authEntries.length} authorization entr${authEntries.length === 1 ? 'y' : 'ies'}, but SmartDrop expected ${expected.length}.`, + ); + } + + const remainingExpected = expected.map((entry) => normalizeExpectedAuthKey(entry)); + + for (const entry of authEntries) { + const actual = decodeAuthEntryContractFunction(entry); + const actualKey = normalizeExpectedAuthKey(actual); + const matchIndex = remainingExpected.indexOf(actualKey); + + if (matchIndex === -1) { + throw new SecurityError( + `Transaction signing was blocked because the simulated authorization targets ${actual.contractId}.${actual.functionName}, which is not expected for this SmartDrop action.`, + ); + } + + remainingExpected.splice(matchIndex, 1); + } + + if (remainingExpected.length > 0) { + throw new SecurityError( + 'Transaction signing was blocked because the simulation is missing an expected SmartDrop authorization entry.', + ); + } +} + // ── SorobanService class ────────────────────────────────────────────────────── /** @@ -292,7 +519,7 @@ export class SorobanService { poolId: string, userAddress: string, amount: string, - walletApi: any // Freighter API instance + walletApi: FreighterWalletApi // Freighter API instance ): Promise { const poolContract = this.poolContracts.get(poolId); if (!poolContract) { @@ -328,20 +555,26 @@ export class SorobanService { }; } + validateSimulationAuth(simulation, [ + { + contractId: poolContract.contractId(), + functionName: 'lock_assets', + }, + ]); + // Prepare transaction for signing const preparedTransaction = rpc.assembleTransaction(transaction, simulation).build(); // Request signature from Freighter - const signedTransaction = await walletApi.signTransaction( - preparedTransaction.toXDR(), - { + const signedTransaction = getSignedTransactionXdr( + await walletApi.signTransaction(preparedTransaction.toXDR(), { networkPassphrase: NETWORK_PASSPHRASE, - } + }), ); // Submit transaction const submissionResult = await this.rpcServer.sendTransaction( - TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE) + TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE), ); if (submissionResult.status === 'ERROR') { @@ -359,6 +592,9 @@ export class SorobanService { }; } catch (error) { console.error('Error locking assets:', error); + if (error instanceof SecurityError) { + throw error; + } return { success: false, error: error instanceof Error ? error.message : 'Unknown error', @@ -373,7 +609,7 @@ export class SorobanService { poolId: string, userAddress: string, amount: string, - walletApi: any + walletApi: FreighterWalletApi ): Promise { const poolContract = this.poolContracts.get(poolId); if (!poolContract) { @@ -406,17 +642,23 @@ export class SorobanService { }; } + validateSimulationAuth(simulation, [ + { + contractId: poolContract.contractId(), + functionName: 'unlock_assets', + }, + ]); + const preparedTransaction = rpc.assembleTransaction(transaction, simulation).build(); - const signedTransaction = await walletApi.signTransaction( - preparedTransaction.toXDR(), - { + const signedTransaction = getSignedTransactionXdr( + await walletApi.signTransaction(preparedTransaction.toXDR(), { networkPassphrase: NETWORK_PASSPHRASE, - } + }), ); const submissionResult = await this.rpcServer.sendTransaction( - TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE) + TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE), ); if (submissionResult.status === 'ERROR') { @@ -435,6 +677,9 @@ export class SorobanService { } catch (error) { console.error('Error unlocking assets:', error); + if (error instanceof SecurityError) { + throw error; + } return { success: false, error: error instanceof Error ? error.message : 'Unknown error', @@ -449,7 +694,7 @@ export class SorobanService { poolId: string, userAddress: string, allocationPercentage: number, - walletApi: any + walletApi: FreighterWalletApi ): Promise { const poolContract = this.poolContracts.get(poolId); if (!poolContract) { @@ -489,17 +734,23 @@ export class SorobanService { }; } + validateSimulationAuth(simulation, [ + { + contractId: poolContract.contractId(), + functionName: 'set_boost', + }, + ]); + const preparedTransaction = rpc.assembleTransaction(transaction, simulation).build(); - const signedTransaction = await walletApi.signTransaction( - preparedTransaction.toXDR(), - { + const signedTransaction = getSignedTransactionXdr( + await walletApi.signTransaction(preparedTransaction.toXDR(), { networkPassphrase: NETWORK_PASSPHRASE, - } + }), ); const submissionResult = await this.rpcServer.sendTransaction( - TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE) + TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE), ); if (submissionResult.status === 'ERROR') { @@ -518,6 +769,9 @@ export class SorobanService { } catch (error) { console.error('Error setting boost:', error); + if (error instanceof SecurityError) { + throw error; + } return { success: false, error: error instanceof Error ? error.message : 'Unknown error', @@ -636,7 +890,7 @@ export const unlockAssets = async ({ poolContractId: string; publicKey: string; amount: string; - walletApi: any; + walletApi: FreighterWalletApi; }) => { // Convert display-unit amount to integer stroops before passing as i128. // 1 display unit = 10,000,000 stroops (Stellar's fixed-point precision). @@ -682,9 +936,17 @@ interface SorobanRpcServer { getEvents(request: Parameters[0]): ReturnType; } +type SorobanEventLike = { + inSuccessfulContractCall?: boolean; + topic: xdr.ScVal[]; + value: xdr.ScVal; + ledgerClosedAt: string; + contractId?: string | Contract; + txHash: string; +}; + function parseTxHistoryEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - evt: any, + evt: SorobanEventLike, publicKey: string, ): TxHistoryEntry | null { try { @@ -699,6 +961,11 @@ function parseTxHistoryEvent( if (userAddr !== publicKey) return null; const action: 'lock' | 'unlock' = actionRaw === 'lock_assets' ? 'lock' : 'unlock'; + const poolId = + typeof evt.contractId === 'string' + ? evt.contractId + : evt.contractId?.contractId(); + if (!poolId) return null; const valueNative = scValToNative(evt.value as xdr.ScVal); let amount = '0'; @@ -725,7 +992,7 @@ function parseTxHistoryEvent( action, amount, symbol, - poolId: evt.contractId as string, + poolId, creditsEarned, txHash: evt.txHash as string, }; @@ -768,12 +1035,12 @@ export async function getUserTransactionHistory( ], }, ], - pagination: { limit: 200 }, + limit: 200, }); const entries: TxHistoryEntry[] = []; for (const evt of response.events) { - const entry = parseTxHistoryEvent(evt, publicKey); + const entry = parseTxHistoryEvent(evt as SorobanEventLike, publicKey); if (entry) entries.push(entry); }