diff --git a/__mocks__/cairo/ERC20-241/ERC20OZ081.casm.json b/__mocks__/cairo/ERC20-241/ERC20OZ081.casm similarity index 100% rename from __mocks__/cairo/ERC20-241/ERC20OZ081.casm.json rename to __mocks__/cairo/ERC20-241/ERC20OZ081.casm diff --git a/__tests__/account.outsideExecution.test.ts b/__tests__/account.outsideExecution.test.ts index c80fb6b16..d7c758932 100644 --- a/__tests__/account.outsideExecution.test.ts +++ b/__tests__/account.outsideExecution.test.ts @@ -1,119 +1,344 @@ -import { getStarkKey, utils } from '@scure/starknet'; -import { Provider, Account, cairo } from '../src'; -import { SNIP9_V1_INTERFACE_ID } from '../src/types/outsideExecution'; -import { OutsideExecution } from '../src/utils/outsideExecution'; -import { randomAddress } from '../src/utils/stark'; +// We test here the most common case: an account compatible with ERC-165 and SNIP-9 (v2). +// To limit test duration, these cases are not tested: non ERC165 account, non SNIP-9 account, SNIP9-v1 account. import { - compiledArgentAccount, - compiledArgentAccountCasm, + Provider, + Account, + cairo, + ec, + stark, + CairoCustomEnum, + CairoOption, + CairoOptionVariant, + CallData, + OutsideExecutionVersion, + type OutsideExecutionOptions, + type OutsideTransaction, + constants, + type Call, + Contract, + outsideExecution, + type TypedData, + type Calldata, + src5, +} from '../src'; +import { getSelectorFromName } from '../src/utils/hash'; +import { getDecimalString } from '../src/utils/num'; +import { + compiledArgentX4Account, + compiledErc20OZ, getTestAccount, getTestProvider, } from './config/fixtures'; -const { uint256 } = cairo; - describe('Account and OutsideExecution', () => { - const devnetERC20Address = '0x49D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7'; + const ethAddress = '0x49D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7'; const provider = new Provider(getTestProvider()); - const sender = getTestAccount(provider); - let target: Account; - const targetPK = utils.randomPrivateKey(); - const targetOwner = getStarkKey(targetPK); + const executorAccount = getTestAccount(provider); + let signerAccount: Account; + const targetPK = stark.randomAddress(); + const targetPubK = ec.starkCurve.getStarkKey(targetPK); // For ERC20 transfer outside call - const recipient = randomAddress(); - const transferAmount = 100; + const recipientAccount = executorAccount; + const ethContract = new Contract(compiledErc20OZ.abi, ethAddress, provider); beforeAll(async () => { - // Deploy the target account: - const response = await sender.declareAndDeploy( - { - contract: compiledArgentAccount, - casm: compiledArgentAccountCasm, - constructorCalldata: [0, targetOwner, 1], // signer = targetOwner, guardian = None - }, - { maxFee: 1e18 } - ); + // Deploy the SNIP-9 signer account (ArgentX v 0.4.0, using SNIP-9 v2): + const calldataAX = new CallData(compiledArgentX4Account.abi); + const axSigner = new CairoCustomEnum({ Starknet: { pubkey: targetPubK } }); + const axGuardian = new CairoOption(CairoOptionVariant.None); + const constructorAXCallData = calldataAX.compile('constructor', { + owner: axSigner, + guardian: axGuardian, + }); + const response = await executorAccount.declareAndDeploy({ + contract: compiledArgentX4Account, + classHash: '0x36078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f', + compiledClassHash: '0x7a663375245780bd307f56fde688e33e5c260ab02b76741a57711c5b60d47f6', + constructorCalldata: constructorAXCallData, + }); const targetAddress = response.deploy.contract_address; - target = new Account(provider, targetAddress, targetPK); + signerAccount = new Account(provider, targetAddress, targetPK); - // Transfer some tokens to the target account + // Transfer dust of ETH token to the signer account const transferCall = { - contractAddress: devnetERC20Address, + contractAddress: ethAddress, entrypoint: 'transfer', calldata: { - recipient: targetAddress, - amount: uint256(transferAmount), + recipient: signerAccount.address, + amount: cairo.uint256(1000), }, }; - - const { transaction_hash } = await sender.execute(transferCall, undefined, { maxFee: 1e18 }); + const { transaction_hash } = await executorAccount.execute(transferCall); await provider.waitForTransaction(transaction_hash); }); - it('target account should support SNIP-9', async () => { - const res = await sender.callContract({ - contractAddress: target.address, - entrypoint: 'supports_interface', - calldata: [SNIP9_V1_INTERFACE_ID], + test('getOutsideCall', async () => { + const call1: Call = { + contractAddress: '0x0123', + entrypoint: 'transfer', + calldata: { + recipient: '0xabcd', + amount: cairo.uint256(10), + }, + }; + expect(outsideExecution.getOutsideCall(call1)).toEqual({ + to: '0x0123', + selector: getSelectorFromName(call1.entrypoint), + calldata: ['43981', '10', '0'], }); + }); - expect(res[0]).toBe('0x1'); + test('Build SNIP-9 v2 TypedData', async () => { + const call1: Call = { + contractAddress: '0x0123', + entrypoint: 'transfer', + calldata: { + recipient: '0xabcd', + amount: cairo.uint256(10), + }, + }; + const callOptions: OutsideExecutionOptions = { + caller: '0x1234', + execute_after: 100, + execute_before: 200, + }; + const message: TypedData = outsideExecution.getTypedData( + constants.StarknetChainId.SN_SEPOLIA, + callOptions, + 21, + [call1], + OutsideExecutionVersion.V2 + ); + expect(message).toEqual({ + domain: { + chainId: '0x534e5f5345504f4c4941', + name: 'Account.execute_from_outside', + revision: '1', + version: '2', + }, + message: { + Caller: '0x1234', + Calls: [ + { + Calldata: ['43981', '10', '0'], + Selector: '0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e', + To: '0x0123', + }, + ], + 'Execute After': 100, + 'Execute Before': 200, + Nonce: 21, + }, + primaryType: 'OutsideExecution', + types: { + Call: [ + { + name: 'To', + type: 'ContractAddress', + }, + { + name: 'Selector', + type: 'selector', + }, + { + name: 'Calldata', + type: 'felt*', + }, + ], + OutsideExecution: [ + { + name: 'Caller', + type: 'ContractAddress', + }, + { + name: 'Nonce', + type: 'felt', + }, + { + name: 'Execute After', + type: 'u128', + }, + { + name: 'Execute Before', + type: 'u128', + }, + { + name: 'Calls', + type: 'Call*', + }, + ], + StarknetDomain: [ + { + name: 'name', + type: 'shortstring', + }, + { + name: 'version', + type: 'shortstring', + }, + { + name: 'chainId', + type: 'shortstring', + }, + { + name: 'revision', + type: 'shortstring', + }, + ], + }, + }); }); - it('should execute OutsideExecution flow', async () => { - // Create calls to ERC20 contract: transfer 100 tokens to random address - const calls = [ - { - contractAddress: devnetERC20Address, - entrypoint: 'transfer', - calldata: { - recipient, - amount: uint256(transferAmount), - }, + test('buildExecuteFromOutsideCallData', async () => { + const outsideTransaction: OutsideTransaction = { + outsideExecution: { + caller: '0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691', + nonce: '0x7d0b4b4fce4b236e63d2bb5fc321935d52935cd3b268248cf9cf29c496bd0ae', + execute_after: 500, + execute_before: 600, + calls: [{ to: '0x678', selector: '0x890', calldata: [12, 13] }], }, - ]; + signature: ['0x123', '0x456'], + signerAddress: '0x3b278ebae434f283f9340587a7f2dd4282658ac8e03cb9b0956db23a0a83657', + version: OutsideExecutionVersion.V2, + }; + + const execute: Calldata = outsideExecution.buildExecuteFromOutsideCallData(outsideTransaction); + expect(execute).toEqual([ + '2846891009026995430665703316224827616914889274105712248413538305735679628945', + '3534941323322368687588030484849371698982661160919690922146419787802417549486', + '500', + '600', + '1', + '1656', + '2192', + '2', + '12', + '13', + '2', + '291', + '1110', + ]); + }); + + test('Signer account should support SNIP-9 v2', async () => { + expect(await signerAccount.getSnip9Version()).toBe(OutsideExecutionVersion.V2); + }); + + test('SNIP-9 nonce', async () => { + const nonce = await signerAccount.getSnip9Nonce(); + expect(nonce).toBeDefined(); + expect(await signerAccount.isValidSnip9Nonce(nonce)).toBe(true); + }); - // Prepare time bounds + test('should build and execute outside transactions', async () => { const now_seconds = Math.floor(Date.now() / 1000); const hour_ago = (now_seconds - 3600).toString(); const hour_later = (now_seconds + 3600).toString(); - - // Create OutsideExecution object - const options = { - caller: sender.address, - nonce: await sender.getNonce(), + const callOptions: OutsideExecutionOptions = { + caller: executorAccount.address, execute_after: hour_ago, execute_before: hour_later, }; + const callOptions4: OutsideExecutionOptions = { + ...callOptions, + caller: 'ANY_CALLER', + }; + const call1: Call = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: recipientAccount.address, + amount: cairo.uint256(100), + }, + }; + const call2: Call = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: recipientAccount.address, + amount: cairo.uint256(200), + }, + }; + const call3: Call = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: recipientAccount.address, + amount: cairo.uint256(300), + }, + }; + const call4: Call = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: recipientAccount.address, + amount: cairo.uint256(400), + }, + }; + const outsideTransaction3: OutsideTransaction = await signerAccount.getOutsideTransaction( + callOptions4, + call4 + ); // ANY_CALLER - const outsideExecution = new OutsideExecution(calls, options); - - // Get supported SNIP-9 version of target account - const snip9Version = await target.getSnip9Version(); - expect(snip9Version).toBeDefined(); - - // Sign the outside execution - const data = outsideExecution.getTypedData(await sender.getChainId(), snip9Version!); - const signature = await target.signMessage(data); - - // Execute OutsideExecution from sender account - const response = await sender.executeFromOutside( - outsideExecution, - signature, - target.address, - {}, - snip9Version + const outsideTransaction1: OutsideTransaction = await signerAccount.getOutsideTransaction( + callOptions, + call3 + ); // designated caller + expect(outsideTransaction3.version).toBe(OutsideExecutionVersion.V2); + expect(outsideTransaction1.signerAddress).toBe(signerAccount.address); + expect(outsideTransaction3.outsideExecution.caller).toBe(constants.OutsideExecutionCallerAny); + expect(outsideTransaction1.outsideExecution.caller).toBe(executorAccount.address); + expect(outsideTransaction1.outsideExecution.execute_after).toBe(hour_ago); + expect(outsideTransaction1.outsideExecution.execute_before).toBe(hour_later); + expect(outsideTransaction1.outsideExecution.calls).toEqual([ + { + to: ethAddress, + selector: '0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e', + calldata: [getDecimalString(recipientAccount.address), '300', '0'], + }, + ]); + // get outside transaction of a multiCall : + const outsideTransaction2: OutsideTransaction = await signerAccount.getOutsideTransaction( + callOptions, + [call1, call2] ); - await provider.waitForTransaction(response.transaction_hash); - - // Check recipient's balance - const balanceRes = await provider.callContract({ - contractAddress: devnetERC20Address, - entrypoint: 'balanceOf', - calldata: { - user: recipient, + expect(outsideTransaction2.outsideExecution.calls).toEqual([ + { + to: ethAddress, + selector: '0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e', + calldata: [getDecimalString(recipientAccount.address), '100', '0'], }, - }); - expect(balanceRes[0]).toBe('0x64'); // 100 tokens + { + to: ethAddress, + selector: '0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e', + calldata: [getDecimalString(recipientAccount.address), '200', '0'], + }, + ]); + const bal0 = (await ethContract.balanceOf(signerAccount.address)) as bigint; + const res0 = await executorAccount.executeFromOutside(outsideTransaction2); + await provider.waitForTransaction(res0.transaction_hash); + const bal1 = (await ethContract.balanceOf(signerAccount.address)) as bigint; + expect(bal0 - bal1).toBe(300n); + // execute multi outside transactions + const res1 = await executorAccount.executeFromOutside([ + outsideTransaction1, + outsideTransaction3, + ]); + await provider.waitForTransaction(res1.transaction_hash); + const bal2 = (await ethContract.balanceOf(signerAccount.address)) as bigint; + expect(bal1 - bal2).toBe(700n); + expect(await signerAccount.isValidSnip9Nonce(outsideTransaction3.outsideExecution.nonce)).toBe( + false + ); + }); + + test('ERC165 introspection', async () => { + const isSNIP9 = await src5.supportsInterface( + provider, + signerAccount.address, + constants.SNIP9_V2_INTERFACE_ID + ); + expect(isSNIP9).toBe(true); }); }); diff --git a/__tests__/config/fixtures.ts b/__tests__/config/fixtures.ts index 5068e7360..b32c7e648 100644 --- a/__tests__/config/fixtures.ts +++ b/__tests__/config/fixtures.ts @@ -30,6 +30,8 @@ const readContractSierra = (name: string): CompiledSierra => export const compiledOpenZeppelinAccount = readContract('Account'); export const compiledErc20 = readContract('ERC20'); export const compiledErc20Echo = readContract('ERC20-echo'); +export const compiledErc20OZ = readContractSierra('cairo/ERC20-241/ERC20OZ081.sierra'); +export const compiledErc20OZCasm = readContractSierraCasm('cairo/ERC20-241/ERC20OZ081'); export const compiledL1L2 = readContract('l1l2_compiled'); export const compiledTypeTransformation = readContract('contract'); export const compiledMulticall = readContract('multicall'); @@ -41,8 +43,8 @@ export const compiledHelloSierraCasm = readContractSierraCasm('cairo/helloSierra export const compiledComplexSierra = readContractSierra('cairo/complexInput/complexInput'); export const compiledC1Account = readContractSierra('cairo/account/accountOZ080'); export const compiledC1AccountCasm = readContractSierraCasm('cairo/account/accountOZ080'); -export const compiledArgentAccount = readContractSierra('cairo/account/accountArgent040'); -export const compiledArgentAccountCasm = readContractSierraCasm('cairo/account/accountArgent040'); +export const compiledArgentX4Account = readContractSierra('cairo/account/accountArgent040'); +export const compiledArgentX4AccountCasm = readContractSierraCasm('cairo/account/accountArgent040'); export const compiledC1v2 = readContractSierra('cairo/helloCairo2/compiled'); export const compiledC1v2Casm = readContractSierraCasm('cairo/helloCairo2/compiled'); export const compiledC210 = readContractSierra('cairo/cairo210/cairo210.sierra'); diff --git a/src/account/default.ts b/src/account/default.ts index 6723c913f..951246743 100644 --- a/src/account/default.ts +++ b/src/account/default.ts @@ -1,6 +1,12 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { SPEC } from 'starknet-types-07'; -import { UDC, ZERO } from '../constants'; +import { + OutsideExecutionCallerAny, + SNIP9_V1_INTERFACE_ID, + SNIP9_V2_INTERFACE_ID, + UDC, + ZERO, +} from '../constants'; import { Provider, ProviderInterface } from '../provider'; import { Signer, SignerInterface } from '../signer'; import { @@ -42,16 +48,21 @@ import { } from '../types'; import { ETransactionVersion, ETransactionVersion3, ResourceBounds } from '../types/api'; import { - EOutsideExecutionVersion, - SNIP9_V1_INTERFACE_ID, - SNIP9_V2_INTERFACE_ID, + OutsideExecutionVersion, + type OutsideExecution, + type OutsideExecutionOptions, + type OutsideTransaction, } from '../types/outsideExecution'; -import { OutsideExecution, buildExecuteFromOutsideCallData } from '../utils/outsideExecution'; +import { + buildExecuteFromOutsideCallData, + getOutsideCall, + getTypedData, +} from '../utils/outsideExecution'; import { CallData } from '../utils/calldata'; import { extractContractHashes, isSierra } from '../utils/contract'; import { parseUDCEvent } from '../utils/events'; import { calculateContractAddressFromHash } from '../utils/hash'; -import { toBigInt, toCairoBool } from '../utils/num'; +import { isHex, toBigInt, toCairoBool, toHex } from '../utils/num'; import { parseContract } from '../utils/provider'; import { isString } from '../utils/shortString'; import { supportsInterface } from '../utils/src5'; @@ -586,85 +597,180 @@ export class Account extends Provider implements AccountInterface { ); } - public async getSnip9Version(): Promise { - // Check for support of the SNIP-9 version 2 interface - - const supportsSnip9V2 = await supportsInterface(this, this.address, SNIP9_V2_INTERFACE_ID); - - if (supportsSnip9V2) { - return EOutsideExecutionVersion.V2; + /** + * Verify if an account is compatible with SNIP-9 outside execution, and with which version of this standard. + * @returns {OutsideExecutionVersion} Not compatible, V1, V2. + * @example + * ```typescript + * const result = myAccount.getSnip9Version(); + * // result = "V1" + * ``` + */ + public async getSnip9Version(): Promise { + if (await supportsInterface(this, this.address, SNIP9_V2_INTERFACE_ID)) { + return OutsideExecutionVersion.V2; } - - // Check for support of the SNIP-9 version 1 interface - const supportsSnip9V1 = await supportsInterface(this, this.address, SNIP9_V1_INTERFACE_ID); - - if (supportsSnip9V1) { - return EOutsideExecutionVersion.V1; + if (await supportsInterface(this, this.address, SNIP9_V1_INTERFACE_ID)) { + return OutsideExecutionVersion.V1; } - // Account does not support either version 2 or version 1 - return EOutsideExecutionVersion.UNSUPPORTED; + return OutsideExecutionVersion.UNSUPPORTED; } + /** + * Verify if a SNIP-9 nonce has not yet been used by the account. + * @param {BigNumberish} nonce SNIP-9 nonce to test. + * @returns {boolean} true if SNIP-9 nonce not yet used. + * @example + * ```typescript + * const result = myAccount.isValidSnip9Nonce(1234); + * // result = true + * ``` + */ public async isValidSnip9Nonce(nonce: BigNumberish): Promise { try { const call: Call = { contractAddress: this.address, entrypoint: 'is_valid_outside_execution_nonce', - calldata: CallData.compile({ nonce }), + calldata: [toHex(nonce)], }; - const resp = await this.callContract(call); - - // Transforming the result into a boolean value return BigInt(resp[0]) !== 0n; } catch (error) { throw new Error(`Failed to check if nonce is valid: ${error}`); } } - public async executeFromOutside( - outsideExecution: OutsideExecution, - signature: Signature, - targetAddress: string, - opts: UniversalDetails, - version?: EOutsideExecutionVersion | undefined - ): Promise<{ transaction_hash: string }> { - // if the version is not specified, try to determine the latest supported version - const supportedVersion = - version ?? (await new Account(this, targetAddress, this.signer).getSnip9Version()); - // choose the correct entrypoint - let entrypoint: string; - if (supportedVersion === EOutsideExecutionVersion.V1) { - entrypoint = 'execute_from_outside'; - } else if (supportedVersion === EOutsideExecutionVersion.V2) { - entrypoint = 'execute_from_outside_v2'; - } else { - throw new Error('Unsupported OutsideExecution version'); + /** + * Outside transaction needs a specific SNIP-9 nonce, that we get in this function. + * A SNIP-9 nonce can be any number not yet used ; no ordering is needed. + * @returns {string} an Hex string of a SNIP-9 nonce. + * @example + * ```typescript + * const result = myAccount.getSnip9Nonce(); + * // result = "0x28a612590dbc36927933c8ee0f357eee639c8b22b3d3aa86949eed3ada4ac55" + * ``` + */ + public async getSnip9Nonce(): Promise { + const nonce = randomAddress(); + const isValidNonce = await this.isValidSnip9Nonce(nonce); + if (!isValidNonce) { + return this.getSnip9Nonce(); } + return nonce; + } - // prepare the call - const call = { - contractAddress: targetAddress, - entrypoint, - calldata: buildExecuteFromOutsideCallData(outsideExecution, signature), + /** + * Creates an object containing transaction(s) that can be executed by an other account with` Account.executeFromOutside()`, called Outside Transaction. + * @param {OutsideExecutionOptions} options Parameters of the transaction(s). + * @param {AllowArray} calls Transaction(s) to execute. + * @param {OutsideExecutionVersion} [version] SNIP-9 version of the Account that creates the outside transaction. + * @param {BigNumberish} [nonce] Outside Nonce. + * @returns {OutsideTransaction} and object that can be used in `Account.executeFromOutside()` + * @example + * ```typescript + * const now_seconds = Math.floor(Date.now() / 1000); + * const callOptions: OutsideExecutionOptions = { + caller: executorAccount.address, execute_after: now_seconds - 3600, execute_before: now_seconds + 3600 }; + * const call1: Call = { contractAddress: ethAddress, entrypoint: 'transfer', calldata: { + * recipient: recipientAccount.address, amount: cairo.uint256(100) } }; + * const outsideTransaction1: OutsideTransaction = await signerAccount.getOutsideTransaction(callOptions, call3); + * // result = { + * // outsideExecution: { + * // caller: '0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691', + * // nonce: '0x28a612590dbc36927933c8ee0f357eee639c8b22b3d3aa86949eed3ada4ac55', + * // execute_after: 1723650229, execute_before: 1723704229, calls: [[Object]] }, + * // signature: Signature { + * // r: 67518627037915514985321278857825384106482999609634873287406612756843916814n, + * // s: 737198738569840639192844101690009498983611654458636624293579534560862067709n, recovery: 0 }, + * // signerAddress: '0x655f8fd7c4013c07cf12a92184aa6c314d181443913e21f7e209a18f0c78492', + * // version: '2' + * // } + * ``` + */ + public async getOutsideTransaction( + options: OutsideExecutionOptions, + calls: AllowArray, + version?: OutsideExecutionVersion, + nonce?: BigNumberish + ): Promise { + if (!isHex(options.caller) && options.caller !== 'ANY_CALLER') { + throw new Error(`The caller ${options.caller} is not valid.`); + } + const codedCaller: string = isHex(options.caller) ? options.caller : OutsideExecutionCallerAny; + const myCalls: Call[] = Array.isArray(calls) ? calls : [calls]; + const supportedVersion = version ?? (await this.getSnip9Version()); + if (!supportedVersion) { + throw new Error('This account is not handling outside transactions.'); + } + const myNonce = nonce ? toHex(nonce) : await this.getSnip9Nonce(); + const message = getTypedData( + await this.getChainId(), + { + caller: codedCaller, + execute_after: options.execute_after, + execute_before: options.execute_before, + }, + myNonce, + myCalls, + supportedVersion + ); + const sign: Signature = await this.signMessage(message); + const toExecute: OutsideExecution = { + caller: codedCaller, + nonce: myNonce, + execute_after: options.execute_after, + execute_before: options.execute_before, + calls: myCalls.map(getOutsideCall), + }; + return { + outsideExecution: toExecute, + signature: sign, + signerAddress: this.address, + version: supportedVersion, }; - // execute the call - return this.execute(call, opts); } - public async getSnip9Nonce(account: Account): Promise { - // create random nonce (in felt range) - const nonce = randomAddress(); - // check if nonce is already used - const isValidNonce = await account.isValidSnip9Nonce(nonce); - if (!isValidNonce) { - // if it is, try again - return this.getSnip9Nonce(account); - } - // if not, return it - return nonce; - // TODO process errors + /** + * An account B executes a transaction that has been signed by an account A. + * Fees are paid by B. + * @param {AllowArray} outsideTransaction the signed transaction generated by `Account.getOutsideTransaction()`. + * @param {UniversalDetails} [opts] same options than `Account.execute()`. + * @returns {InvokeFunctionResponse} same response than `Account.execute()`. + * @example + * ```typescript + * const outsideTransaction1: OutsideTransaction = await signerAccount.getOutsideTransaction(callOptions, call1); + * const outsideTransaction2: OutsideTransaction = await signerAccount.getOutsideTransaction(callOptions4, call4); + * const result = await myAccount.executeFromOutside([ + outsideTransaction1, + outsideTransaction2, + ]); + * // result = { transaction_hash: '0x11233...`} + * ``` + */ + public async executeFromOutside( + outsideTransaction: AllowArray, + opts?: UniversalDetails + ): Promise { + const myOutsideTransactions = Array.isArray(outsideTransaction) + ? outsideTransaction + : [outsideTransaction]; + const multiCall: Call[] = myOutsideTransactions.map((outsideTx: OutsideTransaction) => { + let entrypoint: string; + if (outsideTx.version === OutsideExecutionVersion.V1) { + entrypoint = 'execute_from_outside'; + } else if (outsideTx.version === OutsideExecutionVersion.V2) { + entrypoint = 'execute_from_outside_v2'; + } else { + throw new Error('Unsupported OutsideExecution version'); + } + return { + contractAddress: toHex(outsideTx.signerAddress), + entrypoint, + calldata: buildExecuteFromOutsideCallData(outsideTx), + }; + }); + return this.execute(multiCall, opts); } /* diff --git a/src/account/interface.ts b/src/account/interface.ts index 2b6808adb..d86405a2c 100644 --- a/src/account/interface.ts +++ b/src/account/interface.ts @@ -28,10 +28,7 @@ import { SimulateTransactionResponse, TypedData, UniversalDeployerContractPayload, - UniversalDetails, } from '../types'; -import { EOutsideExecutionVersion } from '../types/outsideExecution'; -import { OutsideExecution } from '../utils/outsideExecution'; export abstract class AccountInterface extends ProviderInterface { public abstract address: string; @@ -365,27 +362,6 @@ export abstract class AccountInterface extends ProviderInterface { */ public abstract hashMessage(typedData: TypedData): Promise; - /** - * Get the supported version of the outside execution for the account - * @returns the supported version of the outside execution - */ - public abstract getSnip9Version(): Promise; - - /** - * Execute an outside execution on the account - * @param outsideExecution - the outside execution object - * @param signature - the signature for the outside execution - * @param targetAddress - the address of an account on which the outside execution will be executed - * @param version - the version of the outside execution standard - */ - public abstract executeFromOutside( - outsideExecution: OutsideExecution, - signature: Signature, - targetAddress: string, - opts: UniversalDetails, - version?: EOutsideExecutionVersion - ): Promise; - /** * Gets the nonce of the account with respect to a specific block * diff --git a/src/constants.ts b/src/constants.ts index ba87c28ff..06137197a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -76,3 +76,9 @@ export const RPC_NODES = { `https://free-rpc.nethermind.io/sepolia-juno/${RPC_DEFAULT_VERSION}`, ], } as const; + +export const OutsideExecutionCallerAny = '0x414e595f43414c4c4552'; // encodeShortString('ANY_CALLER') +export const SNIP9_V1_INTERFACE_ID = + '0x68cfd18b92d1907b8ba3cc324900277f5a3622099431ea85dd8089255e4181'; +export const SNIP9_V2_INTERFACE_ID = + '0x1d1144bb2138366ff28d8e9ab57456b1d332ac42196230c3a602003c89872'; diff --git a/src/index.ts b/src/index.ts index 3f9339877..6bf0681ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export * as provider from './utils/provider'; export * as selector from './utils/hash/selector'; export * as events from './utils/events'; export * as outsideExecution from './utils/outsideExecution'; +export * as src5 from './utils/src5'; export * from './utils/responseParser'; export * from './utils/cairoDataTypes/uint256'; export * from './utils/cairoDataTypes/uint512'; diff --git a/src/types/outsideExecution.ts b/src/types/outsideExecution.ts index ae3af5907..d62d3c9f9 100644 --- a/src/types/outsideExecution.ts +++ b/src/types/outsideExecution.ts @@ -1,9 +1,11 @@ -import { BigNumberish, RawArgs } from './lib'; +import { BigNumberish, RawArgs, type Signature } from './lib'; export interface OutsideExecutionOptions { + /** authorized executer of the transaction(s): Hex address or "ANY_CALLER" or shortString.encodeShortString(constants.OutsideExecutionCallerAny) */ caller: string; - nonce: BigNumberish; + /** Unix timestamp of the beginning of the timeframe */ execute_after: BigNumberish; + /** Unix timestamp of the end of the timeframe */ execute_before: BigNumberish; } @@ -13,10 +15,20 @@ export interface OutsideCall { calldata: RawArgs; } -export const SNIP9_V1_INTERFACE_ID = - '0x68cfd18b92d1907b8ba3cc324900277f5a3622099431ea85dd8089255e4181'; -export const SNIP9_V2_INTERFACE_ID = - '0x1d1144bb2138366ff28d8e9ab57456b1d332ac42196230c3a602003c89872'; +export interface OutsideExecution { + caller: string; + nonce: BigNumberish; + execute_after: BigNumberish; + execute_before: BigNumberish; + calls: OutsideCall[]; +} + +export interface OutsideTransaction { + outsideExecution: OutsideExecution; + signature: Signature; + signerAddress: BigNumberish; + version: OutsideExecutionVersion; +} export const OutsideExecutionTypesV1 = { StarkNetDomain: [ @@ -62,7 +74,7 @@ export const OutsideExecutionTypesV2 = { ], }; -export enum EOutsideExecutionVersion { +export enum OutsideExecutionVersion { UNSUPPORTED = '0', V1 = '1', V2 = '2', diff --git a/src/utils/outsideExecution.ts b/src/utils/outsideExecution.ts index dd33c91aa..392229dec 100644 --- a/src/utils/outsideExecution.ts +++ b/src/utils/outsideExecution.ts @@ -1,81 +1,37 @@ import { CallData } from './calldata'; -import { getSelectorFromName } from './selector'; -import { encodeShortString } from './shortString'; -import { Call } from '../types/lib'; -import { Calldata, Signature } from '../types'; -import { formatSignature } from './stark'; - +import { Call, type BigNumberish, type Calldata } from '../types/lib'; import { - OutsideExecutionOptions, - OutsideCall, - EOutsideExecutionVersion, OutsideExecutionTypesV1, OutsideExecutionTypesV2, -} from '../types/outsideExecution'; - -export const OutsideExecutionCallerAny = encodeShortString('ANY_CALLER'); - -export class OutsideExecution { - options: OutsideExecutionOptions; - - calls: Call[]; - - constructor(calls: Call[], options: OutsideExecutionOptions) { - this.calls = calls; - this.options = options; - } - - getTypedData(chainId: string, version: EOutsideExecutionVersion) { - if (version === '1') { - return { - types: OutsideExecutionTypesV1, - primaryType: 'OutsideExecution', - domain: getDomain(chainId, version), - message: { - ...this.options, - calls_len: this.calls.length, - calls: this.calls.map((call) => callToTypedData(call, version)), - }, - }; - } - return { - types: OutsideExecutionTypesV2, - primaryType: 'OutsideExecution', - domain: getDomain(chainId, version), - message: { - Caller: this.options.caller, - Nonce: this.options.nonce, - 'Execute After': this.options.execute_after, - 'Execute Before': this.options.execute_before, - Calls: this.calls.map((call) => callToTypedData(call, version)), - }, - }; - } - - // Returns the ABI representation to be used with Account entrypoints. - // (may still be needed if different versions of the same object are used) - getABIData() { - return { - caller: this.options.caller, - nonce: this.options.nonce, - execute_after: this.options.execute_after, - execute_before: this.options.execute_before, - calls: this.calls.map((call) => getOutsideCall(call)), - }; - } -} - -function getDomain(chainId: string, version: EOutsideExecutionVersion) { - return { - name: 'Account.execute_from_outside', - version, - chainId, - ...(version === '2' ? { revision: '1' } : {}), - }; -} + type OutsideExecutionVersion, + type OutsideCall, + type OutsideExecutionOptions, + type OutsideTransaction, + type TypedData, +} from '../types'; +import { getSelectorFromName } from './hash/selector'; +import { formatSignature } from './stark'; -// converts a Call object to an OutsideCall object that can be used in the OutsideExecution object -function getOutsideCall(call: Call): OutsideCall { +/** + * Converts a Call object to an OutsideCall object that can be used for an Outside Execution. + * @param {Call} call transaction to proceed. + * @returns {OutsideCall} transaction formatted in conformity to SNIP-9 + * @example + * ```typescript + * const call1: Call = { + * contractAddress: '0x0123', + * entrypoint: 'transfer', + * calldata: { recipient: '0xabcd', amount: cairo.uint256(10) }, + * }; + * const result = outsideExecution.getOutsideCall(call1); + * // result = { + * // to: '0x0123', + * // selector: getSelectorFromName(call1.entrypoint), + * // calldata: ['43981', '10', '0'], + * //} + * ``` + */ +export function getOutsideCall(call: Call): OutsideCall { const callData = call.calldata ?? []; const callDataCompiled = Array.isArray(callData) ? callData : CallData.compile(callData); return { @@ -85,8 +41,8 @@ function getOutsideCall(call: Call): OutsideCall { }; } -// represents a call object as a typed data, supporting both v1 and v2 versions -function callToTypedData(call: Call, version: EOutsideExecutionVersion) { +/** represents a call object as a typed data, supporting both v1 and v2 versions */ +function callToTypedData(call: Call, version: OutsideExecutionVersion) { const outsideCall = getOutsideCall(call); if (version === '1') { return { @@ -102,15 +58,112 @@ function callToTypedData(call: Call, version: EOutsideExecutionVersion) { }; } -// Builds a CallData for the execute_from_outside() entrypoint. -export function buildExecuteFromOutsideCallData( - outsideExecution: OutsideExecution, - signature: Signature -): Calldata { - const abiData = outsideExecution.getABIData(); - const formattedSignature = formatSignature(signature); +function getDomain(chainId: string, version: OutsideExecutionVersion) { + return { + name: 'Account.execute_from_outside', + version, + chainId, + ...(version === '2' ? { revision: '1' } : {}), + }; +} + +/** + * Build a TypedData message that will be used for an Outside execution. + * @param {string} chainId The encoded string of the name of network. + * @param {OutsideExecutionOptions} options Parameters related to an Outside Execution. + * @param {BigNumberish} nonce Outside execution nonce (not to confuse with normal transaction nonce). + * @param {Call[]} myCalls transaction(s) to proceed. + * @param {OutsideExecutionVersion} version SNIP-9 V1 or V2. + * @returns {TypedData} SNIP-12 message conform to SNIP-9. + * @example + * ```typescript + * const callOptions: OutsideExecutionOptions = { + * caller: '0x1234', + * execute_after: 100, + * execute_before: 200, + * }; + * const result: TypedData = outsideExecution.getTypedData( + * constants.StarknetChainId.SN_SEPOLIA, + * callOptions, + * 21, + * [call1], + * EOutsideExecutionVersion.V2 + * ); + * // result = { + * // domain: { + * // chainId: '0x534e5f5345504f4c4941', + * // name: 'Account.execute_from_outside', + * // revision: '1', + * // version: '2', + * // }, + * // message: { + * // Caller: '0x1234', + * // ... + * ``` + */ +export function getTypedData( + chainId: string, + options: OutsideExecutionOptions, + nonce: BigNumberish, + myCalls: Call[], + version: OutsideExecutionVersion +): TypedData { + if (version === '1') { + return { + types: OutsideExecutionTypesV1, + primaryType: 'OutsideExecution', + domain: getDomain(chainId, version), + message: { + ...options, + nonce, + calls_len: myCalls.length, + calls: myCalls.map((call) => callToTypedData(call, version)), + }, + }; + } + return { + types: OutsideExecutionTypesV2, + primaryType: 'OutsideExecution', + domain: getDomain(chainId, version), + message: { + Caller: options.caller, + Nonce: nonce, + 'Execute After': options.execute_after, + 'Execute Before': options.execute_before, + Calls: myCalls.map((call) => callToTypedData(call, version)), + }, + }; +} + +/** + * Builds a CallData for the execute_from_outside() entrypoint. + * @param {OutsideTransaction} outsideTransaction an object that contains all the data for a Outside Execution. + * @returns {Calldata} The Calldata related to this Outside transaction + * @example + * ```typescript + * const outsideTransaction: OutsideTransaction = { + * outsideExecution: { + * caller: '0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691', + * nonce: '0x7d0b4b4fce4b236e63d2bb5fc321935d52935cd3b268248cf9cf29c496bd0ae', + * execute_after: 500, execute_before: 600, + * calls: [{ to: '0x678', selector: '0x890', calldata: [12, 13] }], + * }, + * signature: ['0x123', '0x456'], + * signerAddress: '0x3b278ebae434f283f9340587a7f2dd4282658ac8e03cb9b0956db23a0a83657', + * version: EOutsideExecutionVersion.V2, + * }; + * + * const result: Calldata = outsideExecution.buildExecuteFromOutsideCallData(outsideTransaction); + * // result = ['2846891009026995430665703316224827616914889274105712248413538305735679628945', + * // '3534941323322368687588030484849371698982661160919690922146419787802417549486', + * // '500', '600', '1', '1656', '2192', '2', '12', '13', '2', '291', '1110'] + * ``` + */ +export function buildExecuteFromOutsideCallData(outsideTransaction: OutsideTransaction): Calldata { + const execution = outsideTransaction.outsideExecution; + const formattedSignature = formatSignature(outsideTransaction.signature); return CallData.compile({ - outside_execution: abiData, + outside_execution: execution, signature: formattedSignature, }); } diff --git a/src/utils/src5.ts b/src/utils/src5.ts index ea8e56c1a..4300b8eed 100644 --- a/src/utils/src5.ts +++ b/src/utils/src5.ts @@ -1,17 +1,36 @@ -import { ProviderInterface } from '../provider'; +import { RpcProvider } from '../provider'; +import type { BigNumberish } from '../types'; +import { toHex } from './num'; -export const supportsInterface = async ( - provider: ProviderInterface, - contractAddress: string, - interfaceId: string -): Promise => { - // create a call +/** + * Implementation of ERC165 introspection. + * Verify if a contract has implemented some standard functionalities. + * @param {RpcProvider} provider the provider to access to Starknet. + * @param {BigNumberish} contractAddress the address of the contract to check. + * @param {BigNumberish} interfaceId the hash of the functionality to check. + * @returns {boolean} true if the interfaceId is implemented in this contract. + * @example + * ```typescript + * const snip9InterfaceV2Id = constants.SNIP9_V2_INTERFACE_ID; + * const result = src5.supportsInterface(myProvider, accountContractAddress, snip9InterfaceV2Id); + * // result = true + * ``` + */ +export async function supportsInterface( + provider: RpcProvider, + contractAddress: BigNumberish, + interfaceId: BigNumberish +): Promise { const call = { - contractAddress, + contractAddress: toHex(contractAddress), entrypoint: 'supports_interface', - calldata: [interfaceId], + calldata: [toHex(interfaceId)], }; - // call the contract - const resp = await provider.callContract(call); - return BigInt(resp[0]) !== 0n; -}; + try { + const resp = await provider.callContract(call); + return BigInt(resp[0]) !== 0n; + } catch { + // account not compatible with ERC165 (introspection) + return false; + } +} diff --git a/www/docs/guides/outsideExecution.md b/www/docs/guides/outsideExecution.md index af42a4544..313159603 100644 --- a/www/docs/guides/outsideExecution.md +++ b/www/docs/guides/outsideExecution.md @@ -17,78 +17,251 @@ Outside Execution provides several benefits: ### Check SNIP-9 Support -Before using Outside Execution, check if the account supports it: +The account that will sign the outside transaction has to be compatible with SNIP-9 (V1 or V2). +At mid-2024 : -```typescript -const account = new Account(provider, address, privateKey); -const version = await account.getSnip9Version(); +| account | compatibility | +| :------------: | :-----------: | +| ArgentX v0.3.0 | v1 | +| ArgentX v0.4.0 | v2 | +| Braavos v1.0.0 | v2 | +| OpenZeppelin | ❌ | + +Before using Outside Execution, check if the account that will sign the transaction supports SNIP-9: -if (version != 'UNSUPPORTED') { - console.log(`Account supports SNIP-9 version ${version}`); -} else { - console.log('Account does not support SNIP-9'); +```typescript +const signerAccount = new Account(myProvider, accountAddress, privateKey); +const version = await signerAccount.getSnip9Version(); +if (version === EOutsideExecutionVersion.UNSUPPORTED) { + throw new Error('This account is not SNIP-9 compatible.'); } ``` -### Create an OutsideExecution Object +:::info +The account that will sign the transaction needs to be compatible with SNIP-9. +Nevertheless, the account that will execute the transaction do not needs to be SNIP-9 compatible ; it just needs to have enough fees to pay the transaction. +::: + +### Create an `OutsideTransaction` Object -To create an OutsideExecution object, you need to prepare the calls and options: +To create an OutsideExecution object, you need first to prepare the call: ```typescript -import { outsideExecution, OutsideExecutionOptions, cairo } from 'starknet'; -const { OutsideExecution } = outsideExecution; - -// ... account initialization skipped ... - -const calls = [ - { - contractAddress: erc20Address, - entrypoint: 'transfer', - calldata: { - recipient: recipientAddress, - amount: cairo.uint256(100), - }, +const call1: Call = { + contractAddress: erc20Address, + entrypoint: 'transfer', + calldata: { + recipient: recipientAddress, + amount: cairo.uint256(3n * 10n ** 16n), }, -]; +}; +``` + +Then, you have to initialize some parameters : + +- The `caller` is the address of the account that will execute the outside transaction. +- The transaction can be executed in a time frame that is defined in `execute_after` and `execute_before`, using Unix timestamp. -const options: OutsideExecutionOptions = { - caller: senderAddress, - nonce: await account.getNonce(), +```typescript +const callOptions: OutsideExecutionOptions = { + caller: executorAccount.address, execute_after: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago execute_before: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now }; +``` + +:::warning +You can use the string `"ANY_CALLER"` as content of the `caller` property. To use with care, as anybody that get your `OutsideTransaction` object and execute it. +::: + +To create the `OutsideTransaction` object, you just have to use: + +```typescript +const outsideTransaction1: OutsideTransaction = await signerAccount.getOutsideTransaction( + callOptions, + call1 +); +``` -const outsideExecution = new OutsideExecution(calls, options); +:::note +In the same `OutsideTransaction` object, you can include several transactions. So, with only one signature of the signer Account, you can generate an `OutsideTransaction` object that performs many things: + +```typescript +const callOptions: OutsideExecutionOptions = { + caller: executorAccount.address, + execute_after: 100, + execute_before: 200, +}; +const call1 = { + contractAddress: ethAddress, + entrypoint: 'approve', + calldata: { + spender: account2.address, + amount: cairo.uint256(2n * 10n ** 16n), + }, +}; +const call2 = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: account1.address, + amount: cairo.uint256(1n * 10n ** 16n), + }, +}; +const outsideTransaction1: OutsideTransaction = await signerAccount.getOutsideTransaction( + callOptions, + [call1, call2] +); ``` -### Sign the Outside Execution +::: -The account owner needs to sign the Outside Execution: +### Process the Outside Execution + +Finally, if you are in the time frame, you can perform the Outside Execution, using the executor Account : ```typescript -const chainId = await account.getChainId(); -const data = outsideExecution.getTypedData(chainId, version); -const signature = await account.signMessage(data); +const executorAccount = new Account(provider, executorAddress, executorPrivateKey); +const response = await executorAccount.executeFromOutside(outsideTransaction1); +await provider.waitForTransaction(response.transaction_hash); ``` -### Execute the Outside Execution +:::info +If you have created several `OutsideTransaction` objects using the same signer account, you can execute them in any order (no nonce problems). +::: -Finally, execute the Outside Execution from another account: +:::note +In the same command, you can use several `OutsideTransaction` objects created by several signer accounts, even if they are not compatible with the same version of SNIP-9 (V1 or V2): ```typescript -const senderAccount = new Account(provider, senderAddress, senderPrivateKey); -const response = await senderAccount.executeFromOutside( - outsideExecution, - signature, - account.address, - {}, - version +const outsideTransaction1: OutsideTransaction = await accountAX3.getOutsideTransaction( + callOptions, + call1 +); // V1 compatible +const outsideTransaction2: OutsideTransaction = await accountAX4.getOutsideTransaction( + callOptions, + call2 +); // V2 compatible +const res = await executorAccount.executeFromOutside([outsideTransaction1, outsideTransaction2]); +``` + +::: + +## Example of Outside Execution using a Ledger Nano + +In this example, we want to sign, with a Ledger Nano X, several transactions at 6PM. Then a code is automatically launched each hour until the next day at 8AM, verifying if some conditions are reached. The code will then trigger the execution of some of the transactions signed earlier with the Ledger Nano. +By this way, you can pre-sign some transactions with the Ledger, and if during the night something occurs, a backend can execute automatically some of these transactions, **in any order**. +In this process, **the private key of the Ledger account is never exposed**. + +First, create a Ledger account in devnet-rs. You will find some documentation [here](./signature.md#signing-with-a-ledger-hardware-wallet), and an example [here](https://github.com/PhilippeR26/starknet.js-workshop-typescript/blob/main/src/scripts/ledgerNano/4.deployLedgerAccount.ts). + +The initial balances are : + +| account | ETH balance | +| ----------------------: | ----------- | +| Ledger Account | 20.0 | +| Backend executorAccount | 999.9902013 | +| Account1 | 1000.0 | +| Account2 | 1000.0 | + +Now, we can ask the user to sign on its Ledger some outside transactions: + +```typescript +const callOptions: OutsideExecutionOptions = { + caller: executorAccount.address, + execute_after: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago + execute_before: Math.floor(Date.now() / 1000) + 3600 * 14, // 14 hours from now +}; +const call1 = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: account1.address, + amount: cairo.uint256(1n * 10n ** 18n), + }, +}; +const call2 = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: account2.address, + amount: cairo.uint256(2n * 10n ** 18n), + }, +}; +const call3 = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: account1.address, + amount: cairo.uint256(3n * 10n ** 18n), + }, +}; +const call4 = { + contractAddress: ethAddress, + entrypoint: 'transfer', + calldata: { + recipient: account2.address, + amount: cairo.uint256(4n * 10n ** 18n), + }, +}; +console.log("It's 6PM. Before night, we will now pre-sign 3 outside transactions:"); +console.log( + 'Sign now on the Ledger Nano for :\n- Transfer 1 ETH to account1.\n- Transfer 2 ETH to account2.' +); +const outsideTransaction1: OutsideTransaction = await ledgerAccount.getOutsideTransaction( + callOptions, + [call1, call2] ); -await provider.waitForTransaction(response.transaction_hash); +console.log('Sign now on the Ledger Nano for :\n- Transfer 3 ETH to account1.'); +const outsideTransaction2: OutsideTransaction = await ledgerAccount.getOutsideTransaction( + callOptions, + call3 +); + +console.log('Sign now on the Ledger Nano for :\n- Transfer 4 ETH to account1.'); +const outsideTransaction3: OutsideTransaction = await ledgerAccount.getOutsideTransaction( + callOptions, + call4 +); +``` + +Transfer these 3 `OutsideTransaction` objects to the backend. + +Imagine we are 5 hours later, the backend has decided to execute a transaction: + +```typescript +console.log('The backend has detected a situation that execute Transaction 2.'); +const res0 = await executorAccount.executeFromOutside(outsideTransaction2); +await myProvider.waitForTransaction(res0.transaction_hash); ``` -## Related Documentation +The balances are now : + +| account | ETH balance | +| ----------------------: | ----------- | +| Ledger Account | 17.0 | +| Backend executorAccount | 999.9901592 | +| Account1 | 1003.0 | +| Account2 | 1000.0 | + +2 hours later, the backend has decided to execute several transactions: + +```typescript +console.log('The backend has detected a situation that execute simultaneously Transactions 1 & 3.'); +const res1 = await executorAccount.executeFromOutside([outsideTransaction1, outsideTransaction3]); +await myProvider.waitForTransaction(res1.transaction_hash); +``` + +The balances are finally : + +| account | ETH balance | +| ----------------------: | ----------- | +| Ledger Account | 10.0 | +| Backend executorAccount | 999.9901005 | +| Account1 | 1004.0 | +| Account2 | 1006.0 | -- [Signatures](./signature.md) -- [Accounts](./accounts.md) +:::info +The complete code of this example is available [here](https://github.com/PhilippeR26/starknet.js-workshop-typescript/blob/main/src/scripts/Starknet131/Starknet131-devnet/17.outsideExecuteLedger.ts). +:::