diff --git a/packages/account-abstraction/src/__tests__/execute.integration.test.ts b/packages/account-abstraction/src/__tests__/execute.integration.test.ts new file mode 100644 index 0000000..f52a5e4 --- /dev/null +++ b/packages/account-abstraction/src/__tests__/execute.integration.test.ts @@ -0,0 +1,97 @@ +/** + * Integration tests for execute functionality. + * These tests demonstrate the execute integration working against testnet contracts. + * + * NOTE: These tests are skipped by default as they require: + * - A deployed account contract on testnet + * - A funded source account + * - Network connectivity to Stellar testnet + * + * To run these tests: + * 1. Deploy an account contract to testnet + * 2. Update the CONTRACT_ID and SOURCE_ACCOUNT constants + * 3. Remove the .skip from describe.skip + */ + +import { Networks } from '@stellar/stellar-sdk'; +import { AccountContract } from '../account-contract'; +import type { ExecuteOptions } from '../execute'; + +describe.skip('execute integration (testnet)', () => { + // Update these values for actual integration testing + const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + const SOURCE_ACCOUNT = 'GCKFBEIYTKP6RCZX6DSQF22OLNXY2SOGLVUQ6RGE4VW6HKPOLJZX6YTV'; + + let contract: AccountContract; + let executeOptions: ExecuteOptions; + + beforeAll(() => { + contract = new AccountContract(CONTRACT_ID); + + // Mock server for integration testing + // In real integration tests, you would use actual Soroban RPC server + const mockServer = { + getAccount: jest.fn().mockResolvedValue({ + id: SOURCE_ACCOUNT, + sequence: '123456789', + }), + simulateTransaction: jest.fn().mockResolvedValue({ + result: { retval: null }, + }), + sendTransaction: jest.fn().mockResolvedValue({ + status: 'SUCCESS', + hash: 'mock_hash', + result: { retval: null }, + }), + }; + + executeOptions = { + server: mockServer, + sourceAccount: SOURCE_ACCOUNT, + networkPassphrase: Networks.TESTNET, + fee: '100000', + timeout: 300, + }; + }); + + it('should demonstrate execute integration setup', async () => { + // This test demonstrates the integration setup + expect(contract).toBeDefined(); + expect(executeOptions).toBeDefined(); + expect(executeOptions.networkPassphrase).toBe(Networks.TESTNET); + }); + + it('should have executeContract method available', async () => { + expect(typeof contract.executeContract).toBe('function'); + }); + + it('should have simulateExecute method available', async () => { + expect(typeof contract.simulateExecute).toBe('function'); + }); +}); + +/** + * Example of how to set up and run real integration tests: + * + * 1. Install Stellar CLI and deploy account contract to testnet: + * ```bash + * cd contracts/account + * stellar contract deploy --wasm target/wasm32-unknown-unknown/release/account.wasm --network testnet + * ``` + * + * 2. Initialize the contract: + * ```bash + * stellar contract invoke --id --fn initialize --arg --network testnet + * ``` + * + * 3. Update the constants above with actual values and replace mock server with: + * ```typescript + * import { SorobanRpc } from '@stellar/stellar-sdk'; + * const server = new SorobanRpc.Server('https://soroban-testnet.stellar.org'); + * ``` + * + * 4. Remove .skip and run the tests: + * ```bash + * pnpm test --filter @ancore/account-abstraction -- execute.integration.test.ts + * ``` + */ diff --git a/packages/account-abstraction/src/__tests__/execute.test.ts b/packages/account-abstraction/src/__tests__/execute.test.ts new file mode 100644 index 0000000..e1386d9 --- /dev/null +++ b/packages/account-abstraction/src/__tests__/execute.test.ts @@ -0,0 +1,123 @@ +/** + * Unit tests for execute integration. + * Tests XDR encoding, contract execution, and error mapping. + */ + +import { xdr, nativeToScVal } from '@stellar/stellar-sdk'; +import { AccountContract } from '../account-contract'; +import { encodeContractArgs, parseExecuteResult } from '../execute'; +import { mapContractError } from '../errors'; + +describe('execute integration', () => { + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + let contract: AccountContract; + + beforeEach(() => { + contract = new AccountContract(contractId); + }); + + describe('encodeContractArgs', () => { + it('should encode basic types', () => { + const args = ['hello', 42, true, null]; + const encoded = encodeContractArgs(args); + + expect(encoded).toHaveLength(4); + expect(encoded[0]).toEqual(nativeToScVal('hello')); + expect(encoded[1]).toEqual(nativeToScVal(42)); + expect(encoded[2]).toEqual(nativeToScVal(true)); + expect(encoded[3]).toEqual(xdr.ScVal.scvVoid()); + }); + + it('should encode arrays and objects', () => { + const args = [[1, 2, 3], { key: 'value' }]; + const encoded = encodeContractArgs(args); + + expect(encoded).toHaveLength(2); + expect(encoded[0]).toEqual(nativeToScVal([1, 2, 3])); + expect(encoded[1]).toEqual(nativeToScVal({ key: 'value' })); + }); + + it('should handle undefined as void', () => { + const args = [undefined]; + const encoded = encodeContractArgs(args); + + expect(encoded).toHaveLength(1); + expect(encoded[0]).toEqual(xdr.ScVal.scvVoid()); + }); + + it('should throw on encoding errors', () => { + const circular: Record = {}; + circular.self = circular; + + expect(() => encodeContractArgs([circular])).toThrow(/Converting circular structure to JSON/); + }); + }); + + describe('parseExecuteResult', () => { + it('should parse basic types', () => { + const stringVal = nativeToScVal('hello'); + const numberVal = nativeToScVal(42); + const boolVal = nativeToScVal(true); + + expect(parseExecuteResult(stringVal)).toBe('hello'); + expect(parseExecuteResult(numberVal)).toBe(42n); // Numbers become BigInt + expect(parseExecuteResult(boolVal)).toBe(true); + }); + + it('should parse complex types', () => { + const arrayVal = nativeToScVal([1, 2, 3]); + const objectVal = nativeToScVal({ key: 'value' }); + + expect(parseExecuteResult(arrayVal)).toEqual([1n, 2n, 3n]); // Numbers become BigInt + expect(parseExecuteResult(objectVal)).toEqual({ key: 'value' }); + }); + + it('should throw on parsing errors', () => { + const invalidScVal = {} as unknown as xdr.ScVal; + + expect(() => parseExecuteResult(invalidScVal)).toThrow(/Failed to parse contract result/); + }); + }); + + describe('error mapping integration', () => { + it('should map contract-specific errors correctly', () => { + const testCases = [ + { message: 'Already initialized', expectedError: 'AlreadyInitializedError' }, + { message: 'Not initialized', expectedError: 'NotInitializedError' }, + { message: 'Invalid nonce', expectedError: 'InvalidNonceError' }, + { message: 'unauthorized access', expectedError: 'UnauthorizedError' }, + { message: 'unknown error', expectedError: 'ContractInvocationError' }, + ]; + + testCases.forEach(({ message, expectedError }) => { + const mappedError = mapContractError(message, new Error(message)); + expect(mappedError.constructor.name).toBe(expectedError); + }); + }); + }); + + describe('AccountContract integration methods', () => { + it('should have executeContract method', () => { + expect(typeof contract.executeContract).toBe('function'); + }); + + it('should have simulateExecute method', () => { + expect(typeof contract.simulateExecute).toBe('function'); + }); + + it('should build execute invocation correctly', () => { + const invocation = contract.execute( + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + 'transfer', + [ + nativeToScVal('GCKFBEIYTKP6RCZX6DSQF22OLNXY2SOGLVUQ6RGE4VW6HKPOLJZX6YTV'), + nativeToScVal(1000), + ], + 1 + ); + + expect(invocation.method).toBe('execute'); + expect(invocation.args).toHaveLength(4); + }); + }); +}); diff --git a/packages/account-abstraction/src/account-contract.ts b/packages/account-abstraction/src/account-contract.ts index aef5788..41db5d4 100644 --- a/packages/account-abstraction/src/account-contract.ts +++ b/packages/account-abstraction/src/account-contract.ts @@ -4,12 +4,7 @@ */ import type { SessionKey } from '@ancore/types'; -import { - Account, - Contract, - TransactionBuilder, - xdr, -} from '@stellar/stellar-sdk'; +import { Account, Contract, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; import { mapContractError } from './errors'; import { addressToScVal, @@ -21,6 +16,12 @@ import { symbolToScVal, u64ToScVal, } from './xdr-utils'; +import { + executeContract, + simulateExecute, + type ExecuteOptions, + type ExecuteResult, +} from './execute'; /** Options for read calls (getOwner, getNonce, getSessionKey) when using a server */ export interface AccountContractReadOptions { @@ -67,12 +68,7 @@ export class AccountContract { * Build invocation for execute(to, function, args, expected_nonce). * Caller must pass the current nonce (e.g. from getNonce()) for replay protection. */ - execute( - to: string, - fn: string, - args: xdr.ScVal[], - expectedNonce: number - ): InvocationArgs { + execute(to: string, fn: string, args: xdr.ScVal[], expectedNonce: number): InvocationArgs { return { method: 'execute', args: [ @@ -184,6 +180,34 @@ export class AccountContract { return scValToOptionalSessionKey(result); } + /** + * Execute a contract method with full transaction submission. + * Encodes arguments, submits transaction, and returns typed result. + */ + async executeContract( + to: string, + functionName: string, + args: unknown[], + expectedNonce: number, + options: ExecuteOptions + ): Promise> { + return executeContract(this, to, functionName, args, expectedNonce, options); + } + + /** + * Simulate a contract execution without submitting the transaction. + * Useful for testing and gas estimation. + */ + async simulateExecute( + to: string, + functionName: string, + args: unknown[], + expectedNonce: number, + options: Omit + ): Promise { + return simulateExecute(this, to, functionName, args, expectedNonce, options); + } + /** * Simulate a read-only contract call and return the result ScVal. * Throws typed errors on contract/host errors. @@ -197,10 +221,7 @@ export class AccountContract { const { server, sourceAccount } = options; const accountResponse = await server.getAccount(sourceAccount); - const account = new Account( - accountResponse.id, - accountResponse.sequence ?? '0' - ); + const account = new Account(accountResponse.id, accountResponse.sequence ?? '0'); const txBuilder = new TransactionBuilder(account, { fee: '100', @@ -211,7 +232,7 @@ export class AccountContract { const raw = txBuilder.build(); - const sim: any = await server.simulateTransaction(raw); + const sim: unknown = await server.simulateTransaction(raw); if (sim && typeof sim === 'object' && ('error' in sim || 'message' in sim)) { const errMsg = @@ -221,7 +242,7 @@ export class AccountContract { throw mapContractError(String(errMsg), sim); } - const result = (sim as any)?.result?.retval as xdr.ScVal | undefined; + const result = (sim as { result?: { retval?: xdr.ScVal } })?.result?.retval; if (result === undefined) { throw mapContractError('No return value from simulation', sim); } diff --git a/packages/account-abstraction/src/execute.ts b/packages/account-abstraction/src/execute.ts new file mode 100644 index 0000000..91402d8 --- /dev/null +++ b/packages/account-abstraction/src/execute.ts @@ -0,0 +1,251 @@ +/** + * Execute integration for AccountContract. + * Handles XDR encoding, contract invocation, and typed response parsing. + */ + +import { + Account, + TransactionBuilder, + xdr, + nativeToScVal, + scValToNative, +} from '@stellar/stellar-sdk'; +import { AccountContract } from './account-contract'; +import { mapContractError } from './errors'; + +/** Options for executing contract calls */ +export interface ExecuteOptions { + server: { + getAccount(accountId: string): Promise<{ id: string; sequence: string }>; + simulateTransaction(tx: unknown): Promise; + sendTransaction(tx: unknown): Promise; + }; + sourceAccount: string; + /** Network passphrase (e.g. Networks.TESTNET) */ + networkPassphrase: string; + /** Transaction fee in stroops */ + fee?: string; + /** Transaction timeout in seconds */ + timeout?: number; +} + +/** Result of a successful contract execution */ +export interface ExecuteResult { + /** Parsed return value from the contract */ + result: T; + /** Transaction hash */ + hash: string; + /** Raw transaction result */ + raw: unknown; +} + +/** + * Encode JavaScript values to XDR ScVal for contract arguments. + * Supports common types: string, number, boolean, arrays, objects. + */ +export function encodeContractArgs(args: unknown[]): xdr.ScVal[] { + return args.map((arg) => { + if (arg === null || arg === undefined) { + return xdr.ScVal.scvVoid(); + } + + // Use stellar-sdk's native encoding for most types + try { + return nativeToScVal(arg); + } catch (error) { + throw new Error(`Failed to encode argument ${JSON.stringify(arg)}: ${error}`); + } + }); +} + +/** + * Parse contract execution result to typed output. + * Returns the native JavaScript value from the ScVal. + */ +export function parseExecuteResult(result: xdr.ScVal): T { + try { + return scValToNative(result) as T; + } catch (error) { + throw new Error(`Failed to parse contract result: ${error}`); + } +} + +/** + * Execute a contract method with full transaction submission. + * Encodes arguments, submits transaction, and parses the result. + */ +export async function executeContract( + contract: AccountContract, + to: string, + functionName: string, + args: unknown[], + expectedNonce: number, + options: ExecuteOptions +): Promise> { + const { server, sourceAccount, networkPassphrase } = options; + const fee = options.fee ?? '100000'; // Default 0.01 XLM + const timeout = options.timeout ?? 180; + + try { + // Encode arguments to XDR + const encodedArgs = encodeContractArgs(args); + + // Build the execute invocation + const invocation = contract.execute(to, functionName, encodedArgs, expectedNonce); + + // Get source account for transaction building + const accountResponse = await server.getAccount(sourceAccount); + const account = new Account(accountResponse.id, accountResponse.sequence ?? '0'); + + // Build transaction + const operation = contract.buildInvokeOperation(invocation); + const txBuilder = new TransactionBuilder(account, { + fee, + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(timeout); + + const transaction = txBuilder.build(); + + // Submit transaction + const submitResult: unknown = await server.sendTransaction(transaction); + + // Check for transaction errors + if ( + submitResult && + typeof submitResult === 'object' && + submitResult !== null && + (('status' in submitResult && (submitResult as { status: string }).status === 'ERROR') || + 'error' in submitResult) + ) { + const errorMsg = + ('error' in submitResult ? (submitResult as { error?: string }).error : undefined) ?? + ('result_xdr' in submitResult + ? (submitResult as { result_xdr?: string }).result_xdr + : undefined) ?? + 'Transaction failed'; + throw mapContractError(String(errorMsg), submitResult); + } + + // Parse successful result + let parsedResult: T; + if ( + submitResult && + typeof submitResult === 'object' && + submitResult !== null && + 'result' in submitResult && + typeof (submitResult as { result: unknown }).result === 'object' && + (submitResult as { result: unknown }).result !== null && + 'retval' in (submitResult as { result: { retval?: unknown } }).result + ) { + const retval = (submitResult as { result: { retval: xdr.ScVal } }).result.retval; + parsedResult = parseExecuteResult(retval); + } else { + // For successful transactions without explicit return value + parsedResult = null as T; + } + + return { + result: parsedResult, + hash: + (submitResult && + typeof submitResult === 'object' && + submitResult !== null && + 'hash' in submitResult + ? (submitResult as { hash: string }).hash + : undefined) ?? + (submitResult && + typeof submitResult === 'object' && + submitResult !== null && + 'id' in submitResult + ? (submitResult as { id: string }).id + : undefined) ?? + 'unknown', + raw: submitResult, + }; + } catch (error) { + // Map known contract errors + if (error instanceof Error) { + throw mapContractError(error.message, error); + } + throw mapContractError('Contract execution failed', error); + } +} + +/** + * Simulate a contract execution without submitting the transaction. + * Useful for testing and gas estimation. + */ +export async function simulateExecute( + contract: AccountContract, + to: string, + functionName: string, + args: unknown[], + expectedNonce: number, + options: Omit +): Promise { + const { server, sourceAccount, networkPassphrase } = options; + + try { + // Encode arguments to XDR + const encodedArgs = encodeContractArgs(args); + + // Build the execute invocation + const invocation = contract.execute(to, functionName, encodedArgs, expectedNonce); + + // Get source account for transaction building + const accountResponse = await server.getAccount(sourceAccount); + const account = new Account(accountResponse.id, accountResponse.sequence ?? '0'); + + // Build simulation transaction + const operation = contract.buildInvokeOperation(invocation); + const txBuilder = new TransactionBuilder(account, { + fee: '100', + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(180); + + const transaction = txBuilder.build(); + + // Simulate transaction + const simResult: unknown = await server.simulateTransaction(transaction); + + // Check for simulation errors + if ( + simResult && + typeof simResult === 'object' && + simResult !== null && + ('error' in simResult || 'message' in simResult) + ) { + const errorMsg = + ('error' in simResult ? (simResult as { error?: string }).error : undefined) ?? + ('message' in simResult ? (simResult as { message?: string }).message : undefined) ?? + 'Simulation failed'; + throw mapContractError(String(errorMsg), simResult); + } + + // Parse simulation result + const result = + simResult && + typeof simResult === 'object' && + simResult !== null && + 'result' in simResult && + typeof (simResult as { result: unknown }).result === 'object' && + (simResult as { result: unknown }).result !== null && + 'retval' in (simResult as { result: { retval?: unknown } }).result + ? (simResult as { result: { retval: xdr.ScVal } }).result.retval + : undefined; + if (result === undefined) { + throw mapContractError('No return value from simulation', simResult); + } + + return parseExecuteResult(result); + } catch (error) { + if (error instanceof Error) { + throw mapContractError(error.message, error); + } + throw mapContractError('Contract simulation failed', error); + } +} diff --git a/packages/account-abstraction/src/index.ts b/packages/account-abstraction/src/index.ts index 808148b..0ab9eb7 100644 --- a/packages/account-abstraction/src/index.ts +++ b/packages/account-abstraction/src/index.ts @@ -7,10 +7,15 @@ export const AA_VERSION = '0.1.0'; export { AccountContract } from './account-contract'; -export type { - AccountContractReadOptions, - InvocationArgs, -} from './account-contract'; +export type { AccountContractReadOptions, InvocationArgs } from './account-contract'; + +export { + executeContract, + simulateExecute, + encodeContractArgs, + parseExecuteResult, +} from './execute'; +export type { ExecuteOptions, ExecuteResult } from './execute'; export { AccountContractError,