From 9f9763654551eeab390eb4019cc3c12b1e5312f9 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 13 May 2026 00:11:15 -0400 Subject: [PATCH] Align executeContract behavior across account types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RPC path now polls for confirmation and walks transitions itself, returning the same {transactionId, transitions, outputs} shape as the local path. The SDK does not ask the wallet to decrypt — record outputs surface as raw record1... ciphertexts on the RPC path. - executeTransaction aliased to writeContract everywhere (matches the Aleo wallet adapter spec); the full-lifecycle action lives as executeContract. - outputs is the called function's transition only on both paths; inner cross-program transitions are surfaced via transitions[]. - Extract waitForConfirmation and extractTransitions to shared core utilities. Position-based top-level identification replaces name matching. --- .../src/actions/wallet/executeContract.ts | 38 ++++-- .../core/src/clients/decorators/wallet.ts | 9 +- packages/core/src/contract/getContract.ts | 11 +- packages/core/src/index.ts | 6 +- packages/core/src/types/proving.ts | 2 +- packages/core/src/utils/extractTransitions.ts | 61 +++++++++ .../core/src/utils/waitForConfirmation.ts | 44 +++++++ .../actions/wallet/executeContract.test.ts | 119 +++++++++++++++++- .../test/clients/createWalletClient.test.ts | 35 +++++- .../core/test/contract/getContract.test.ts | 74 ++++++++--- packages/provable/src/index.ts | 80 +++--------- .../provable/test/execute.integration.test.ts | 4 +- 12 files changed, 368 insertions(+), 115 deletions(-) create mode 100644 packages/core/src/utils/extractTransitions.ts create mode 100644 packages/core/src/utils/waitForConfirmation.ts diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index c6feed21..92f5c311 100644 --- a/packages/core/src/actions/wallet/executeContract.ts +++ b/packages/core/src/actions/wallet/executeContract.ts @@ -1,6 +1,8 @@ import { AccountNotFoundError, ProvingNotConfiguredError } from '../../errors/errors.js' import type { Client } from '../../clients/createClient.js' import type { RawExecuteResult } from '../../types/proving.js' +import { waitForConfirmation } from '../../utils/waitForConfirmation.js' +import { extractTransitions } from '../../utils/extractTransitions.js' export type ExecuteContractParameters = { program: string @@ -17,13 +19,20 @@ export type ExecuteContractReturnType = RawExecuteResult /** * Executes a program function end-to-end: build, prove, broadcast, wait for - * confirmation, and return raw output strings. + * confirmation, and return per-transition outputs. * * Behavior by account type: - * - Local account: proves (locally or via DPS), broadcasts, waits, returns outputs - * - RPC account: delegates entire flow to the connected wallet + * - Local account: proves (locally or via DPS), broadcasts, waits, decrypts owned record + * outputs with the self-custodied view key, returns outputs. + * - RPC account: wallet submits and proves; the SDK then polls the chain for confirmation + * and walks the transitions itself. The SDK does NOT ask the wallet to decrypt — that's + * a permission boundary the dApp shouldn't cross. Record outputs surface as raw + * `record1...` ciphertexts; plaintext outputs surface verbatim. + * + * The RPC path requires a transport that can reach the chain (e.g. an HTTP transport, + * or a fallback that includes one). A wallet-only transport will time out on the + * confirmation poll. * - * Throws if execute is not configured on the proving config. * Use simulateContract for local-only execution without broadcasting. */ export async function executeContract( @@ -36,18 +45,27 @@ export async function executeContract( } if (account.type === 'rpc') { - // RPC account — wallet handles everything - return client.request({ + // 1. Submit via wallet — adapter transport returns the tx id. Param shape mirrors + // writeContract: the wallet handles fee + program-source resolution internally, so + // `fee` and `programSource` on this action don't translate to the wire call. + const txId = await client.request({ method: 'executeTransaction', params: { programName: params.program, functionName: params.function, inputs: params.inputs, - fee: params.fee, - programSource: params.programSource, - imports: params.imports, + privateFee: params.privateFee, + imports: params.imports ? Object.keys(params.imports) : undefined, }, - }) as Promise + }) as string + + // 2. Wait for chain confirmation via the same transport. + const confirmedTx = await waitForConfirmation(client, txId) + + // 3. Walk transitions; no decryptor (see docstring). + const { transitions, outputs } = extractTransitions(confirmedTx) + + return { transactionId: txId, transitions, outputs } } if (account.type === 'local') { diff --git a/packages/core/src/clients/decorators/wallet.ts b/packages/core/src/clients/decorators/wallet.ts index fcd73f82..7de7a6b9 100644 --- a/packages/core/src/clients/decorators/wallet.ts +++ b/packages/core/src/clients/decorators/wallet.ts @@ -16,10 +16,12 @@ import type { Client } from '../createClient.js' export type WalletActions = { sendTransaction: (params: SendTransactionParameters) => Promise writeContract: (params: WriteContractParameters) => Promise + /** Alias for writeContract — matches the Aleo wallet adapter spec (submit and return a transaction id). */ + executeTransaction: (params: WriteContractParameters) => Promise /** Execute locally and return outputs without broadcasting (local accounts only) */ simulateContract: (params: SimulateContractParameters) => Promise - /** Build, broadcast, wait for confirmation, and return outputs. Matches wallet adapter standard naming. */ - executeTransaction: (params: ExecuteContractParameters) => Promise + /** Build, broadcast, wait for confirmation, and return per-transition outputs. */ + executeContract: (params: ExecuteContractParameters) => Promise deployContract: (params: DeployContractParameters) => Promise signMessage: (params: SignMessageParameters) => Promise transfer: (params: TransferParameters) => Promise @@ -39,8 +41,9 @@ export function walletActions(client: Client): WalletActions { return { sendTransaction: (params) => sendTransaction(client, params), writeContract: (params) => writeContract(client, params), + executeTransaction: (params) => writeContract(client, params), simulateContract: (params) => simulateContract(client, params), - executeTransaction: (params) => executeContract(client, params), + executeContract: (params) => executeContract(client, params), deployContract: (params) => deployContract(client, params), signMessage: (params) => signMessage(client, params), transfer: (params) => transfer(client, params), diff --git a/packages/core/src/contract/getContract.ts b/packages/core/src/contract/getContract.ts index a6b3dd8e..9b4dd1a5 100644 --- a/packages/core/src/contract/getContract.ts +++ b/packages/core/src/contract/getContract.ts @@ -256,7 +256,7 @@ export function getContract(params: GetContractParameters): ContractInstance { } return async (execParams: ContractExecuteParams) => { validateFunction(prop) - const result = await walletClient.executeTransaction({ + const result = await walletClient.executeContract({ program, function: prop, inputs: resolveInputs(execParams.inputs, prop), @@ -264,20 +264,19 @@ export function getContract(params: GetContractParameters): ContractInstance { imports: { ...contractImports, ...execParams.imports }, }) - // Build per-transition parsed results + // Build per-transition parsed results. + // Same-program transitions: parse with local ABI. Foreign: loose parse. const transitions: ContractTransitionResult[] = (result.transitions ?? []).map(t => ({ transitionId: t.transitionId, program: t.program, function: t.function, - // Same-program transitions: parse with local ABI. Foreign: loose parse. outputs: t.program === program ? parseOutputs(t.outputs, t.function) : t.outputs.map(o => parseLooseOutput(o)), })) - // Top-level outputs: the transition matching the called program/function - const topLevel = transitions.find(t => t.program === program && t.function === prop) - const outputs = topLevel?.outputs ?? parseOutputs(result.outputs, prop) + // Raw `outputs` is already the called function's transition outputs (set by extractTransitions). + const outputs = parseOutputs(result.outputs, prop) return { transactionId: result.transactionId, transitions, outputs } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c46ff21..9c8b2cab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,6 +123,8 @@ export { parseValue, encodeValue, type ParsedValue } from './utils/values.js' export { parseRecordPlaintext, parseRecordPlaintextLoose, toString, serializeRecord, encodeInputs, getRecordDef, getInputTypes } from './utils/records.js' export { parsePrimitive, parsePlaintext } from './utils/parsePrimitives.js' export { parseAbi } from './utils/parseAbi.js' +export { waitForConfirmation } from './utils/waitForConfirmation.js' +export { extractTransitions, type Decryptor } from './utils/extractTransitions.js' // Transports export { createTransport } from './transports/createTransport.js' @@ -204,8 +206,8 @@ export { readMapping } from './actions/public/readMapping.js' // Wallet Actions (standalone) export { simulateContract, type SimulateContractParameters, type SimulateContractReturnType } from './actions/wallet/simulateContract.js' -export { executeContract as executeTransaction, type ExecuteContractParameters, type ExecuteContractReturnType } from './actions/wallet/executeContract.js' -export { writeContract } from './actions/wallet/writeContract.js' +export { executeContract, type ExecuteContractParameters, type ExecuteContractReturnType } from './actions/wallet/executeContract.js' +export { writeContract, executeTransaction, type WriteContractParameters, type WriteContractReturnType } from './actions/wallet/writeContract.js' export { deployContract } from './actions/wallet/deployContract.js' export { sendTransaction } from './actions/wallet/sendTransaction.js' export { signMessage } from './actions/wallet/signMessage.js' diff --git a/packages/core/src/types/proving.ts b/packages/core/src/types/proving.ts index 75427f9b..b334fa3d 100644 --- a/packages/core/src/types/proving.ts +++ b/packages/core/src/types/proving.ts @@ -64,7 +64,7 @@ export type RawExecuteResult = { transactionId: string /** Per-transition results with program/function metadata */ transitions: RawTransitionResult[] - /** Flat projection of all transition outputs for backwards compatibility */ + /** Outputs of the called function's transition only — inner cross-program transition outputs live in `transitions[]`. */ outputs: string[] } diff --git a/packages/core/src/utils/extractTransitions.ts b/packages/core/src/utils/extractTransitions.ts new file mode 100644 index 00000000..74bab9d7 --- /dev/null +++ b/packages/core/src/utils/extractTransitions.ts @@ -0,0 +1,61 @@ +import type { RawTransitionResult } from '../types/proving.js' + +/** + * Decryption strategy for a single record ciphertext. + * + * Returns the plaintext string if the caller can decrypt it, or `null` if not + * (e.g. not owned, no view key available). The {@link extractTransitions} + * function calls this for each record output and drops the entry when `null` + * is returned — matching the convention that unowned records do not appear in + * the caller's outputs. + * + * When no decryptor is provided, record ciphertexts pass through as raw + * `record1...` strings. This is the right shape for RPC accounts, where the + * dApp should not be brokering decryption on behalf of the wallet. + */ +export type Decryptor = (ciphertext: string) => string | null + +/** + * Walk a confirmed transaction's `execution.transitions[]`, optionally decrypt + * record ciphertexts, and return the structured per-transition outputs plus + * the top-level transition's outputs. + * + * Aleo execution semantics guarantee that the outer/called transition is the + * last element of `execution.transitions[]` — inner cross-program transitions + * are recorded before their callers. So `outputs` is derived from + * `transitions.at(-1)`, with no name matching needed. + */ +export function extractTransitions( + tx: any, + decrypt?: Decryptor, +): { transitions: RawTransitionResult[]; outputs: string[] } { + const rawTransitions: RawTransitionResult[] = [] + + for (const transition of tx.execution?.transitions ?? []) { + const transitionOutputs: string[] = [] + for (const output of transition.outputs ?? []) { + if (!output.value) continue + const isRecordCiphertext = + (output.type === 'record' || output.type === 'record_with_dynamic_id') && + typeof output.value === 'string' && + output.value.startsWith('record1') + + if (isRecordCiphertext && decrypt) { + const plaintext = decrypt(output.value) + if (plaintext !== null) transitionOutputs.push(plaintext) + // else: not owned — drop (matches local-path semantics) + } else { + // Not a record ciphertext, OR no decryptor (RPC path) — pass through verbatim. + transitionOutputs.push(output.value) + } + } + rawTransitions.push({ + transitionId: transition.id ?? '', + program: transition.program ?? '', + function: transition.function ?? '', + outputs: transitionOutputs, + }) + } + + return { transitions: rawTransitions, outputs: rawTransitions.at(-1)?.outputs ?? [] } +} diff --git a/packages/core/src/utils/waitForConfirmation.ts b/packages/core/src/utils/waitForConfirmation.ts new file mode 100644 index 00000000..a1071a41 --- /dev/null +++ b/packages/core/src/utils/waitForConfirmation.ts @@ -0,0 +1,44 @@ +import type { Client } from '../clients/createClient.js' +import type { ConfirmedTransaction } from '../types/block.js' +import { FinalizeRevertError, TransactionTimeoutError } from '../errors/errors.js' + +const DEFAULT_TIMEOUT_MS = 300_000 +const POLL_INTERVAL_MS = 5_000 + +/** + * Poll the chain until `txId` is confirmed, then return the inner transaction object. + * + * Goes through `client.request({ method: 'getConfirmedTransaction', ... })`, so the + * caller's transport must be able to reach the chain (HTTP transport or a fallback + * that includes one). Wallet-only transports will fail every poll and time out. + * + * Throws `FinalizeRevertError` if the confirmation envelope reports `status: 'rejected'`. + * Throws `TransactionTimeoutError` if no confirmation arrives within `timeoutMs`. + */ +export async function waitForConfirmation( + client: Client, + txId: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise> { + const startTime = Date.now() + let lastError: unknown + while (Date.now() - startTime < timeoutMs) { + try { + const confirmed = await client.request({ + method: 'getConfirmedTransaction', + params: { id: txId }, + }) as ConfirmedTransaction | null + if (confirmed) { + if (confirmed.status === 'rejected') { + throw new FinalizeRevertError(txId) + } + return confirmed.transaction + } + } catch (e) { + if (e instanceof FinalizeRevertError) throw e + lastError = e + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + } + throw new TransactionTimeoutError({ transactionId: txId, timeoutMs, cause: lastError as Error | undefined }) +} diff --git a/packages/core/test/actions/wallet/executeContract.test.ts b/packages/core/test/actions/wallet/executeContract.test.ts index 0d5a29da..4b037de7 100644 --- a/packages/core/test/actions/wallet/executeContract.test.ts +++ b/packages/core/test/actions/wallet/executeContract.test.ts @@ -20,26 +20,133 @@ describe('executeContract', () => { await expect(executeContract(client, baseParams)).rejects.toThrow(AccountNotFoundError) }) - it('delegates to transport for RPC account', async () => { - const request = vi.fn().mockResolvedValue({ transactionId: 'at1rpc', outputs: ['100u64'] }) + it('RPC: submits via wallet, polls for confirmation, returns RawExecuteResult', async () => { + // Wallet's executeTransaction returns just the tx id. The SDK then polls + // getConfirmedTransaction itself and walks the transitions. + const request = vi.fn().mockImplementation(async ({ method }: { method: string }) => { + if (method === 'executeTransaction') return 'at1submitted' + if (method === 'getConfirmedTransaction') return { + status: 'accepted', + type: 'execute', + index: 0, + finalize: [], + transaction: { + execution: { + transitions: [ + { + id: 'au1outer', + program: 'token.aleo', + function: 'mint', + outputs: [{ value: '100u64', type: 'public' }], + }, + ], + }, + }, + } + throw new Error(`Unexpected method: ${method}`) + }) const client = { account: { type: 'rpc', address: 'aleo1abc', sign: vi.fn() }, request, } as any const result = await executeContract(client, baseParams) - expect(result).toEqual({ transactionId: 'at1rpc', outputs: ['100u64'] }) - expect(request).toHaveBeenCalledWith({ + + expect(result.transactionId).toBe('at1submitted') + expect(result.transitions).toHaveLength(1) + expect(result.transitions[0]!.transitionId).toBe('au1outer') + expect(result.transitions[0]!.outputs).toEqual(['100u64']) + expect(result.outputs).toEqual(['100u64']) + + // Two RPC roundtrips: submit, then poll. + expect(request).toHaveBeenCalledTimes(2) + expect(request).toHaveBeenNthCalledWith(1, { method: 'executeTransaction', params: { programName: 'token.aleo', functionName: 'mint', inputs: ['aleo1abc', '100u64'], - fee: 1000n, - programSource: undefined, + privateFee: undefined, imports: undefined, }, }) + expect(request).toHaveBeenNthCalledWith(2, { + method: 'getConfirmedTransaction', + params: { id: 'at1submitted' }, + }) + }) + + it('RPC: forwards wallet-adapter param shape (privateFee + imports as string[], no fee/programSource)', async () => { + const request = vi.fn().mockImplementation(async ({ method }: { method: string }) => { + if (method === 'executeTransaction') return 'at1submitted' + if (method === 'getConfirmedTransaction') return { + status: 'accepted', type: 'execute', index: 0, finalize: [], + transaction: { execution: { transitions: [] } }, + } + throw new Error(`Unexpected method: ${method}`) + }) + const client = { + account: { type: 'rpc', address: 'aleo1abc', sign: vi.fn() }, + request, + } as any + + await executeContract(client, { + ...baseParams, + privateFee: true, + programSource: 'program token.aleo;', // not forwarded — wallet has its own resolution + imports: { 'credits.aleo': 'program credits.aleo;' }, + }) + + expect(request).toHaveBeenNthCalledWith(1, { + method: 'executeTransaction', + params: { + programName: 'token.aleo', + functionName: 'mint', + inputs: ['aleo1abc', '100u64'], + privateFee: true, + imports: ['credits.aleo'], // Record → string[] of keys + }, + }) + }) + + it('RPC: record ciphertexts pass through as raw strings (no decryption)', async () => { + // RPC path intentionally doesn't ask the wallet to decrypt — record outputs + // surface as `record1...` ciphertexts. The dApp's contract proxy can decide + // what to do with them. + const request = vi.fn().mockImplementation(async ({ method }: { method: string }) => { + if (method === 'executeTransaction') return 'at1submitted' + if (method === 'getConfirmedTransaction') return { + status: 'accepted', type: 'execute', index: 0, finalize: [], + transaction: { + execution: { + transitions: [ + { + id: 'au1outer', + program: 'token.aleo', + function: 'mint', + outputs: [ + { value: 'record1abc...', type: 'record' }, + { value: '42field', type: 'public' }, + ], + }, + ], + }, + }, + } + throw new Error(`Unexpected method: ${method}`) + }) + const client = { + account: { type: 'rpc', address: 'aleo1abc', sign: vi.fn() }, + request, + } as any + + const result = await executeContract(client, baseParams) + + expect(result.outputs).toEqual(['record1abc...', '42field']) + // The dApp should NOT see a wallet.decrypt call — that permission boundary + // stays inside the wallet. + const methods = request.mock.calls.map(([req]) => req.method) + expect(methods).not.toContain('decrypt') }) it('calls proving.execute for local account with execute configured', async () => { diff --git a/packages/core/test/clients/createWalletClient.test.ts b/packages/core/test/clients/createWalletClient.test.ts index 30b6bae5..bde68a4d 100644 --- a/packages/core/test/clients/createWalletClient.test.ts +++ b/packages/core/test/clients/createWalletClient.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import { createWalletClient } from '../../src/clients/createWalletClient.js' import { custom } from '../../src/transports/custom.js' +import * as veilCore from '../../src/index.js' describe('createWalletClient', () => { it('creates a wallet client with wallet actions', () => { @@ -13,17 +14,43 @@ describe('createWalletClient', () => { expect(client.sendTransaction).toBeTypeOf('function') expect(client.writeContract).toBeTypeOf('function') expect(client.executeTransaction).toBeTypeOf('function') + expect(client.executeContract).toBeTypeOf('function') expect(client.deployContract).toBeTypeOf('function') expect(client.signMessage).toBeTypeOf('function') expect(client.transfer).toBeTypeOf('function') }) - it('executeTransaction is an alias for writeContract', () => { - const transport = custom({ request: vi.fn() }) + it('executeTransaction and writeContract produce identical requests', async () => { + const request = vi.fn().mockResolvedValue('at1tx_identical') + const transport = custom({ request }) const mockAccount = { type: 'rpc' as const, address: 'aleo1abc', sign: vi.fn(), signMessage: vi.fn() } const client = createWalletClient({ account: mockAccount, transport }) - expect(client.executeTransaction).toBeTypeOf('function') - expect(client.writeContract).toBeTypeOf('function') + const params = { + program: 'token.aleo', + function: 'transfer', + inputs: ['aleo1recipient', '100u64'], + privateFee: false, + } + + const writeResult = await client.writeContract(params) + const writeCall = request.mock.calls.at(-1) + request.mockClear() + + const execResult = await client.executeTransaction(params) + const execCall = request.mock.calls.at(-1) + + // Both call sites should produce byte-identical RPC requests and returns. + expect(execCall).toEqual(writeCall) + expect(execResult).toBe(writeResult) + expect(execResult).toBe('at1tx_identical') + }) + + it('top-level executeTransaction export === writeContract', () => { + // Public API guarantee: the package-level `executeTransaction` resolves to the + // light `writeContract`, matching the Aleo wallet adapter spec semantics. + expect(veilCore.executeTransaction).toBe(veilCore.writeContract) + // And the heavy lifecycle action is exported under its own name, distinct from writeContract. + expect(veilCore.executeContract).not.toBe(veilCore.writeContract) }) }) diff --git a/packages/core/test/contract/getContract.test.ts b/packages/core/test/contract/getContract.test.ts index 5eb285ca..1ed2984d 100644 --- a/packages/core/test/contract/getContract.test.ts +++ b/packages/core/test/contract/getContract.test.ts @@ -116,12 +116,12 @@ describe('getContract', () => { }) }) - it('execute proxy delegates to walletClient.executeTransaction', async () => { - const executeTransaction = vi.fn().mockResolvedValue({ transactionId: 'tx1', outputs: ['100u64'] }) + it('execute proxy delegates to walletClient.executeContract', async () => { + const executeContract = vi.fn().mockResolvedValue({ transactionId: 'tx1', transitions: [], outputs: ['100u64'] }) const mockWallet = { writeContract: vi.fn(), simulateContract: vi.fn(), - executeTransaction, + executeContract, key: 'wallet', name: 'test', request: vi.fn(), transport: { config: {} as any, request: vi.fn() }, uid: 'test', extend: vi.fn(), @@ -140,7 +140,7 @@ describe('getContract', () => { await contract.execute.mint({ inputs: ['aleo1abc', '100u64'] }) - expect(executeTransaction).toHaveBeenCalledWith({ + expect(executeContract).toHaveBeenCalledWith({ program: 'token.aleo', function: 'mint', inputs: ['aleo1abc', '100u64'], @@ -330,12 +330,13 @@ describe('getContract', () => { }) it('execute auto-parses outputs with transactionId', async () => { - const executeTransaction = vi.fn().mockResolvedValue({ + const executeContract = vi.fn().mockResolvedValue({ transactionId: 'tx123', + transitions: [], outputs: ['{ owner: aleo1abc.private, points: 1000u64.private, _nonce: 0group.public }'], }) const mockWallet = { - writeContract: vi.fn(), simulateContract: vi.fn(), executeTransaction, + writeContract: vi.fn(), simulateContract: vi.fn(), executeContract, key: 'wallet', name: 'test', request: vi.fn(), transport: { config: {} as any, request: vi.fn() }, uid: 'test', extend: vi.fn(), @@ -414,7 +415,7 @@ describe('getContract', () => { describe('getContract execute proxy — per-transition outputs', () => { it('returns structured transitions from execute result', async () => { - const executeTransaction = vi.fn().mockResolvedValue({ + const executeContract = vi.fn().mockResolvedValue({ transactionId: 'at1abc', transitions: [ { @@ -429,7 +430,7 @@ describe('getContract execute proxy — per-transition outputs', () => { const mockWallet = { account: { type: 'local', address: 'aleo1abc', sign: vi.fn() }, writeContract: vi.fn(), - executeTransaction, + executeContract, simulateContract: vi.fn(), } @@ -449,7 +450,7 @@ describe('getContract execute proxy — per-transition outputs', () => { }) it('parses transition with dynamicId record output', async () => { - const executeTransaction = vi.fn().mockResolvedValue({ + const executeContract = vi.fn().mockResolvedValue({ transactionId: 'at1dyn', transitions: [ { @@ -464,7 +465,7 @@ describe('getContract execute proxy — per-transition outputs', () => { const mockWallet = { account: { type: 'local', address: 'aleo1abc', sign: vi.fn() }, writeContract: vi.fn(), - executeTransaction, + executeContract, simulateContract: vi.fn(), } @@ -506,7 +507,7 @@ describe('getContract execute proxy — per-transition outputs', () => { }) it('handles cross-program transitions with loose parsing', async () => { - const executeTransaction = vi.fn().mockResolvedValue({ + const executeContract = vi.fn().mockResolvedValue({ transactionId: 'at1cross', transitions: [ { @@ -522,15 +523,13 @@ describe('getContract execute proxy — per-transition outputs', () => { outputs: ['{\n owner: aleo1abc.private,\n amount: 200u64.private,\n _nonce: 789group.public\n}'], }, ], - outputs: [ - '{\n owner: aleo1abc.private,\n points: 500u64.private,\n _nonce: 456group.public\n}', - '{\n owner: aleo1abc.private,\n amount: 200u64.private,\n _nonce: 789group.public\n}', - ], + // Raw `outputs` is the called function's transition only — inner transitions live in `transitions[]`. + outputs: ['{\n owner: aleo1abc.private,\n amount: 200u64.private,\n _nonce: 789group.public\n}'], }) const mockWallet = { account: { type: 'local', address: 'aleo1abc', sign: vi.fn() }, writeContract: vi.fn(), - executeTransaction, + executeContract, simulateContract: vi.fn(), } @@ -551,15 +550,52 @@ describe('getContract execute proxy — per-transition outputs', () => { expect(result.outputs).toHaveLength(1) }) - it('falls back to flat outputs when transitions not available', async () => { - const executeTransaction = vi.fn().mockResolvedValue({ + it('outputs is empty when no transition matches the called program/function', async () => { + // Defensive case: wallet returns transitions but none match the called program/function. + // The raw layer's extractTransitions returns outputs: [] in that case; the proxy must pass through. + const executeContract = vi.fn().mockResolvedValue({ + transactionId: 'at1mismatch', + transitions: [ + { + transitionId: 'au1other', + program: 'other.aleo', + function: 'something_else', + outputs: ['{\n owner: aleo1abc.private,\n data: 42u64.private,\n _nonce: 1group.public\n}'], + }, + ], + outputs: [], + }) + const mockWallet = { + account: { type: 'local', address: 'aleo1abc', sign: vi.fn() }, + writeContract: vi.fn(), + executeContract, + simulateContract: vi.fn(), + } + + const contract = getContract({ + program: 'token.aleo', + client: mockWallet as any, + }) + + const result = await contract.execute.mint({ inputs: ['aleo1abc', '1000u64'] }) + + expect(result.transactionId).toBe('at1mismatch') + // The unrelated transition is still surfaced under `transitions` (loose-parsed since it's a foreign program). + expect(result.transitions).toHaveLength(1) + expect(result.transitions[0].program).toBe('other.aleo') + // But top-level `outputs` is empty — we don't claim outputs that don't belong to the called function. + expect(result.outputs).toEqual([]) + }) + + it('handles execute result without transitions array', async () => { + const executeContract = vi.fn().mockResolvedValue({ transactionId: 'at1old', outputs: ['100u64'], }) const mockWallet = { account: { type: 'local', address: 'aleo1abc', sign: vi.fn() }, writeContract: vi.fn(), - executeTransaction, + executeContract, simulateContract: vi.fn(), } diff --git a/packages/provable/src/index.ts b/packages/provable/src/index.ts index 4cd4199a..a9e759d4 100644 --- a/packages/provable/src/index.ts +++ b/packages/provable/src/index.ts @@ -22,7 +22,7 @@ import { loadNetwork as loadSdk } from '@provablehq/sdk/dynamic.js' import type { LocalAccount } from '@veil/core' -import type { ProvingConfig, BuildTransactionOptions, BuildDeploymentOptions, SimulateOptions, ExecuteOptions, RawSimulateResult, RawExecuteResult, RawTransitionResult } from '@veil/core' +import type { ProvingConfig, BuildTransactionOptions, BuildDeploymentOptions, SimulateOptions, ExecuteOptions, RawSimulateResult, RawExecuteResult } from '@veil/core' import type { OwnedRecord, RecordProvider, StandaloneRecordScanner, RequestRecordsParameters } from '@veil/core' import type { Network, PublicClient, WalletClient } from '@veil/core' import { @@ -30,13 +30,14 @@ import { createWalletClient, http, BaseError, - TransactionTimeoutError, - FinalizeRevertError, ProvingError, ConfigurationError, classifyBroadcastError, classifyProvingError, + waitForConfirmation, + extractTransitions, } from '@veil/core' +import type { Decryptor } from '@veil/core' import { mnemonicToHDKey, type AleoDerivationId } from './mnemonic.js' export { @@ -340,63 +341,18 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo /** Convert microcredits (Veil API) to credits (SDK API) for priority fee */ const priorityFee = Number(execOptions.fee) / 1_000_000 - /** - * Extract per-transition output strings from a transaction, decrypting owned records. - * Returns structured transitions + flat outputs for backwards compat. - */ - function extractTransitions(tx: any): { transitions: RawTransitionResult[]; outputs: string[] } { - const rawTransitions: RawTransitionResult[] = [] - const allOutputs: string[] = [] - const accountViewKey = options.account ? ViewKey.from_string(options.account.viewKey) : undefined - - for (const transition of tx.execution?.transitions ?? []) { - const transitionOutputs: string[] = [] - for (const output of transition.outputs ?? []) { - if (!output.value) continue - if ((output.type === 'record' || output.type === 'record_with_dynamic_id') && output.value.startsWith('record1') && accountViewKey) { - const ciphertext = RecordCiphertext.fromString(output.value) - if (ciphertext.isOwner(accountViewKey)) { - transitionOutputs.push(ciphertext.decrypt(accountViewKey).toString()) - } - } else { - transitionOutputs.push(output.value) - } + /** Self-custody decryptor: use the local account's view key to decrypt owned record ciphertexts. */ + const accountViewKey = options.account ? ViewKey.from_string(options.account.viewKey) : undefined + const decryptor: Decryptor | undefined = accountViewKey + ? (ciphertext: string) => { + const ct = RecordCiphertext.fromString(ciphertext) + return ct.isOwner(accountViewKey) ? ct.decrypt(accountViewKey).toString() : null } - rawTransitions.push({ - transitionId: transition.id ?? '', - program: transition.program ?? '', - function: transition.function ?? '', - outputs: transitionOutputs, - }) - allOutputs.push(...transitionOutputs) - } - - return { transitions: rawTransitions, outputs: allOutputs } - } + : undefined - /** Poll for confirmed transaction and check finalize status */ - async function waitForConfirmation(txId: string): Promise { - const pollingClient = new AleoNetworkClient(networkUrl) - const timeout = options.confirmationTimeout ?? 300_000 - const startTime = Date.now() - let lastError: unknown - while (Date.now() - startTime < timeout) { - try { - const confirmed = await pollingClient.getConfirmedTransaction(txId) - if (confirmed) { - if (confirmed.status === 'rejected') { - throw new FinalizeRevertError(txId) - } - return confirmed.transaction - } - } catch (e) { - if (e instanceof FinalizeRevertError) throw e - lastError = e // capture for timeout cause - } - await new Promise((resolve) => setTimeout(resolve, 5_000)) - } - throw new TransactionTimeoutError({ transactionId: txId, timeoutMs: timeout, cause: lastError as Error | undefined }) - } + /** Build a Veil publicClient bound to the current networkUrl for chain polling. */ + const buildPollingClient = () => + createPublicClient({ transport: http(networkUrl, { network: network as Network }) }) if (options.mode === 'delegated') { if (!options.proverUrl) throw new ConfigurationError('Delegated execution requires proverUrl. Pass proverUrl to createProvingConfig or createAleoClient.') @@ -429,8 +385,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo const txId = response.transaction?.id if (!txId) throw new ConfigurationError('DPS response did not contain a transaction ID — check prover service configuration.') - const confirmedTx = await waitForConfirmation(txId) - const { transitions, outputs } = extractTransitions(confirmedTx) + const confirmedTx = await waitForConfirmation(buildPollingClient(), txId, options.confirmationTimeout) + const { transitions, outputs } = extractTransitions(confirmedTx, decryptor) return { transactionId: txId, transitions, outputs } } else { @@ -460,8 +416,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw classifyBroadcastError(e) } - const confirmedTx = await waitForConfirmation(txId) - const { transitions, outputs } = extractTransitions(confirmedTx) + const confirmedTx = await waitForConfirmation(buildPollingClient(), txId, options.confirmationTimeout) + const { transitions, outputs } = extractTransitions(confirmedTx, decryptor) return { transactionId: txId, transitions, outputs } } }, diff --git a/packages/provable/test/execute.integration.test.ts b/packages/provable/test/execute.integration.test.ts index 69c71933..bd7bb057 100644 --- a/packages/provable/test/execute.integration.test.ts +++ b/packages/provable/test/execute.integration.test.ts @@ -123,7 +123,7 @@ describe.skipIf(!shouldRun)('execute lifecycle (integration)', () => { provingMode: 'local', }) - const result = await walletClient.executeTransaction({ + const result = await walletClient.executeContract({ program: 'credits.aleo', function: 'transfer_public', inputs: [DEMO_ADDRESS, '1u64'], @@ -168,7 +168,7 @@ describe.skipIf(!shouldRun || !hasDpsCredentials)('delegated execute (integratio consumerId: DPS_CONSUMER_ID, }) - const result = await walletClient.executeTransaction({ + const result = await walletClient.executeContract({ program: 'credits.aleo', function: 'transfer_public', inputs: [DEMO_ADDRESS, '1u64'],