diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md index d80480b7..0642aeea 100644 --- a/MIGRATION-NOTES.md +++ b/MIGRATION-NOTES.md @@ -2,31 +2,43 @@ A collection of random notes pop up during the migration process. -- TODO: review the retry logic -- const { lastRound: firstRound } = suggestedParams! // TODO: document suggested params doesn't have first round anymore - explain the type differences between transact and algod -- remove waitForIndexer - - DO NOT remove it -- ATC was removed as a transaction type in the composer - Fee calc inside the txn constructor - error messages changed, for example, asset tests - `AssetHoldingReference` replaced by `HoldingReference` - `ApplicationLocalReference` replaced by `LocalsReference` -- BoxReference is gone too -- Error name is gone (snapshot tests updated) -- TODO: remove the ATC too - TODO: add interface for breaking change, for example, Transaction -- TODO: simplify signer + account - TODO: take notes of the legacy functions to be removed and communicate with devrels -- TODO: standardise box ref - TODO: keep track of the changes we make to algokit_transact to fit with algosdk - For integration with lora to work: - need to update subscriber to use the new utils and remove algosdk -- TODO: go ahead with resource/fee on build. Need to have backward compatibility, when resource population is set in send, do it but make sure that it only happens once. - `encodeUnsignedSimulateTransaction` was removed from sdk +- can't addatc into the composer anymore, can addTransactionComposer to composer. Adding composer is just cloning the txns from the param composer to the caller composer +- SendAtomicTransactionComposerResults.group is string | undefined +- buildTransactions will include the signer for nested txn now, this was done at the ATC before - Discuss the inconsistency of transaction and txn, txIds, txID - Disucss the naming of foreignApps vs appReferences + access references - Discuss appCall vs applicationCall - SourceMap was renamed to ProgramSourceMap - OnApplicationComplete.UpdateApplicationOC was renamed to OnApplicationComplete.UpdateApplication - ResourceReference (algod) vs AccessReference (utils) +- check for buffer polyfill +- transaction.ts + - sendTransaction takes composer + - getGroupExecutionInfo removed + - getAtomicTransactionComposerTransactions becomes async +- call composer .build instead of atc buildGroup. This will populate resources too +- suggestedParams was removed from AdditionalAtomicTransactionComposerContext +- generated app client will be changed, no references to atc anymore (this was for v2, confirm for v3) +- atc.parseMethodResponse was replaced by app-manager.getABIReturn +- transaction_asserts uses 'noble/ed25519' while composer uses nacl, which one should we use? +- additionalAtcContext was removed from AtomicTransactionComposerToSend +- ABI + - how to construct ABIStruct from string +- Make sure that the python utils also sort resources during resource population +- migration stratefy for EventType.TxnGroupSimulated in utils-ts-debug +- create BuildComposerTransactionsError error type +- TODO: docs for composer simulate workflow + - without calling `build` first => simulate without resource population + - call `build` -> resource population into transactions with signers -> simulate will use the transactions with signers +- review the names of SignedTransactionWrapper diff --git a/packages/sdk/src/abi/index.ts b/packages/sdk/src/abi/index.ts index ba8ad251..5a869826 100644 --- a/packages/sdk/src/abi/index.ts +++ b/packages/sdk/src/abi/index.ts @@ -1,6 +1,6 @@ -export * from './abi_type.js'; -export * from './contract.js'; -export * from './interface.js'; -export * from './method.js'; -export * from './transaction.js'; -export * from './reference.js'; +export * from './abi_type.js' +export * from './contract.js' +export * from './interface.js' +export * from './method.js' +export * from './reference.js' +export * from './transaction.js' diff --git a/packages/sdk/src/composer.ts b/packages/sdk/src/composer.ts index 6db3549a..27c7e349 100644 --- a/packages/sdk/src/composer.ts +++ b/packages/sdk/src/composer.ts @@ -1,38 +1,5 @@ -import type { - AlgodClient, - PendingTransactionResponse, - SimulateRequest, - SimulateTransaction, - SuggestedParams, -} from '@algorandfoundation/algokit-algod-client' -import type { AccessReference, BoxReference, SignedTransaction } from '@algorandfoundation/algokit-transact' -import { OnApplicationComplete, decodeSignedTransaction, getTransactionId } from '@algorandfoundation/algokit-transact' -import { - ABIAddressType, - ABIMethod, - ABIReferenceType, - ABITupleType, - ABIType, - ABIUintType, - ABIValue, - abiCheckTransactionType, - abiTypeIsReference, - abiTypeIsTransaction, -} from './abi/index.js' -import { Address } from './encoding/address.js' -import { assignGroupID } from './group.js' -import { makeApplicationCallTxnFromObject } from './makeTxn.js' -import { TransactionSigner, TransactionWithSigner, isTransactionWithSigner } from './signer.js' -import { arrayEqual, ensureUint64, stringifyJSON } from './utils/utils.js' -import { waitForConfirmation } from './wait.js' - -// First 4 bytes of SHA-512/256 hash of "return" -const RETURN_PREFIX = new Uint8Array([21, 31, 124, 117]) - -// The maximum number of arguments for an application call transaction -const MAX_APP_ARGS = 16 - -export type ABIArgument = ABIValue | TransactionWithSigner +import { PendingTransactionResponse } from '@algorandfoundation/algokit-algod-client' +import { ABIMethod, ABIValue } from './abi/index.js' /** Represents the output from a successful ABI method call. */ export interface ABIResult { @@ -57,679 +24,3 @@ export interface ABIResult { /** The pending transaction information from the method transaction */ txInfo?: PendingTransactionResponse } - -export enum AtomicTransactionComposerStatus { - /** The atomic group is still under construction. */ - BUILDING, - - /** The atomic group has been finalized, but not yet signed. */ - BUILT, - - /** The atomic group has been finalized and signed, but not yet submitted to the network. */ - SIGNED, - - /** The atomic group has been finalized, signed, and submitted to the network. */ - SUBMITTED, - - /** The atomic group has been finalized, signed, submitted, and successfully committed to a block. */ - COMMITTED, -} - -/** - * Add a value to an application call's foreign array. The addition will be as compact as possible, - * and this function will return an index that can be used to reference `valueToAdd` in `array`. - * - * @param valueToAdd - The value to add to the array. If this value is already present in the array, - * it will not be added again. Instead, the existing index will be returned. - * @param array - The existing foreign array. This input may be modified to append `valueToAdd`. - * @param zeroValue - If provided, this value indicated two things: the 0 value is special for this - * array, so all indexes into `array` must start at 1; additionally, if `valueToAdd` equals - * `zeroValue`, then `valueToAdd` will not be added to the array, and instead the 0 indexes will - * be returned. - * @returns An index that can be used to reference `valueToAdd` in `array`. - */ -function populateForeignArray(valueToAdd: Type, array: Type[], zeroValue?: Type): number { - if (zeroValue != null && valueToAdd === zeroValue) { - return 0 - } - - const offset = zeroValue == null ? 0 : 1 - - for (let i = 0; i < array.length; i++) { - if (valueToAdd === array[i]) { - return i + offset - } - } - - array.push(valueToAdd) - return array.length - 1 + offset -} - -/** A class used to construct and execute atomic transaction groups */ -export class AtomicTransactionComposer { - /** The maximum size of an atomic transaction group. */ - static MAX_GROUP_SIZE: number = 16 - - private status = AtomicTransactionComposerStatus.BUILDING - private transactions: TransactionWithSigner[] = [] - private methodCalls: Map = new Map() - private signedTxns: Uint8Array[] = [] - private txIDs: string[] = [] - - /** - * Get the status of this composer's transaction group. - */ - getStatus(): AtomicTransactionComposerStatus { - return this.status - } - - /** - * Get the number of transactions currently in this atomic group. - */ - count(): number { - return this.transactions.length - } - - /** - * Create a new composer with the same underlying transactions. The new composer's status will be - * BUILDING, so additional transactions may be added to it. - */ - clone(): AtomicTransactionComposer { - const theClone = new AtomicTransactionComposer() - - theClone.transactions = this.transactions.map(({ txn, signer }) => { - // Create a new transaction without the group ID - const txnCopy = { ...txn, group: undefined } - return { - txn: txnCopy, - signer, - } - }) - theClone.methodCalls = new Map(this.methodCalls) - - return theClone - } - - /** - * Add a transaction to this atomic group. - * - * An error will be thrown if the transaction has a nonzero group ID, the composer's status is - * not BUILDING, or if adding this transaction causes the current group to exceed MAX_GROUP_SIZE. - */ - addTransaction(txnAndSigner: TransactionWithSigner): void { - if (this.status !== AtomicTransactionComposerStatus.BUILDING) { - throw new Error('Cannot add transactions when composer status is not BUILDING') - } - - if (this.transactions.length === AtomicTransactionComposer.MAX_GROUP_SIZE) { - throw new Error( - `Adding an additional transaction exceeds the maximum atomic group size of ${AtomicTransactionComposer.MAX_GROUP_SIZE}`, - ) - } - - if (txnAndSigner.txn.group && txnAndSigner.txn.group.some((v) => v !== 0)) { - throw new Error('Cannot add a transaction with nonzero group ID') - } - - this.transactions.push(txnAndSigner) - } - - /** - * Add a smart contract method call to this atomic group. - * - * An error will be thrown if the composer's status is not BUILDING, if adding this transaction - * causes the current group to exceed MAX_GROUP_SIZE, or if the provided arguments are invalid - * for the given method. - */ - addMethodCall({ - appID, - method, - methodArgs, - sender, - suggestedParams, - onComplete, - approvalProgram, - clearProgram, - numGlobalInts, - numGlobalByteSlices, - numLocalInts, - numLocalByteSlices, - extraPages, - appAccounts, - appForeignApps, - appForeignAssets, - boxes, - access, - note, - lease, - rekeyTo, - rejectVersion, - signer, - }: { - /** The ID of the smart contract to call. Set this to 0 to indicate an application creation call. */ - appID: number | bigint - /** The method to call on the smart contract */ - method: ABIMethod - /** The arguments to include in the method call. If omitted, no arguments will be passed to the method. */ - methodArgs?: ABIArgument[] - /** The address of the sender of this application call */ - sender: string | Address - /** Transactions params to use for this application call */ - suggestedParams: SuggestedParams - /** The OnComplete action to take for this application call. If omitted, OnApplicationComplete.NoOp will be used. */ - onComplete?: OnApplicationComplete - /** The approval program for this application call. Only set this if this is an application creation call, or if onComplete is OnApplicationComplete.UpdateApplication */ - approvalProgram?: Uint8Array - /** The clear program for this application call. Only set this if this is an application creation call, or if onComplete is OnApplicationComplete.UpdateApplication */ - clearProgram?: Uint8Array - /** The global integer schema size. Only set this if this is an application creation call. */ - numGlobalInts?: number - /** The global byte slice schema size. Only set this if this is an application creation call. */ - numGlobalByteSlices?: number - /** The local integer schema size. Only set this if this is an application creation call. */ - numLocalInts?: number - /** The local byte slice schema size. Only set this if this is an application creation call. */ - numLocalByteSlices?: number - /** The number of extra pages to allocate for the application's programs. Only set this if this is an application creation call. If omitted, defaults to 0. */ - extraPages?: number - /** Array of Address strings that represent external accounts supplied to this application. If accounts are provided here, the accounts specified in the method args will appear after these. */ - appAccounts?: Array - /** Array of App ID numbers that represent external apps supplied to this application. If apps are provided here, the apps specified in the method args will appear after these. */ - appForeignApps?: Array - /** Array of Asset ID numbers that represent external assets supplied to this application. If assets are provided here, the assets specified in the method args will appear after these. */ - appForeignAssets?: Array - /** The box references for this application call */ - boxes?: BoxReference[] - /** The resource references for this application call */ - access?: AccessReference[] - /** The note value for this application call */ - note?: Uint8Array - /** The lease value for this application call */ - lease?: Uint8Array - /** If provided, the address that the sender will be rekeyed to at the conclusion of this application call */ - rekeyTo?: string | Address - /** The lowest application version for which this transaction should immediately fail. 0 indicates that no version check should be performed. */ - rejectVersion?: number | bigint - /** A transaction signer that can authorize this application call from sender */ - signer: TransactionSigner - }): void { - if (this.status !== AtomicTransactionComposerStatus.BUILDING) { - throw new Error('Cannot add transactions when composer status is not BUILDING') - } - - if (this.transactions.length + method.txnCount() > AtomicTransactionComposer.MAX_GROUP_SIZE) { - throw new Error(`Adding additional transactions exceeds the maximum atomic group size of ${AtomicTransactionComposer.MAX_GROUP_SIZE}`) - } - - if (BigInt(appID) === BigInt(0)) { - if ( - approvalProgram == null || - clearProgram == null || - numGlobalInts == null || - numGlobalByteSlices == null || - numLocalInts == null || - numLocalByteSlices == null - ) { - throw new Error( - 'One of the following required parameters for application creation is missing: approvalProgram, clearProgram, numGlobalInts, numGlobalByteSlices, numLocalInts, numLocalByteSlices', - ) - } - } else if (onComplete === OnApplicationComplete.UpdateApplication) { - if (approvalProgram == null || clearProgram == null) { - throw new Error( - 'One of the following required parameters for OnApplicationComplete.UpdateApplication is missing: approvalProgram, clearProgram', - ) - } - if ( - numGlobalInts != null || - numGlobalByteSlices != null || - numLocalInts != null || - numLocalByteSlices != null || - extraPages != null - ) { - throw new Error( - 'One of the following application creation parameters were set on a non-creation call: numGlobalInts, numGlobalByteSlices, numLocalInts, numLocalByteSlices, extraPages', - ) - } - } else if ( - approvalProgram != null || - clearProgram != null || - numGlobalInts != null || - numGlobalByteSlices != null || - numLocalInts != null || - numLocalByteSlices != null || - extraPages != null - ) { - throw new Error( - 'One of the following application creation parameters were set on a non-creation call: approvalProgram, clearProgram, numGlobalInts, numGlobalByteSlices, numLocalInts, numLocalByteSlices, extraPages', - ) - } - - // Validate that access and legacy foreign arrays are not both specified - if (access && (appAccounts || appForeignApps || appForeignAssets || boxes)) { - throw new Error('Cannot specify both access and legacy foreign arrays (appAccounts, appForeignApps, appForeignAssets, boxes)') - } - - if (methodArgs == null) { - methodArgs = [] - } - - if (methodArgs.length !== method.args.length) { - throw new Error(`Incorrect number of method arguments. Expected ${method.args.length}, got ${methodArgs.length}`) - } - - let basicArgTypes: ABIType[] = [] - let basicArgValues: ABIValue[] = [] - const txnArgs: TransactionWithSigner[] = [] - const refArgTypes: ABIReferenceType[] = [] - const refArgValues: ABIValue[] = [] - const refArgIndexToBasicArgIndex: Map = new Map() - // TODO: Box encoding for ABI - const boxReferences: BoxReference[] = !boxes ? [] : boxes - - for (let i = 0; i < methodArgs.length; i++) { - let argType = method.args[i].type - const argValue = methodArgs[i] - - if (abiTypeIsTransaction(argType)) { - if (!isTransactionWithSigner(argValue) || !abiCheckTransactionType(argType, argValue.txn)) { - throw new Error(`Expected ${argType} TransactionWithSigner for argument at index ${i}`) - } - if (argValue.txn.group && argValue.txn.group.some((v) => v !== 0)) { - throw new Error('Cannot add a transaction with nonzero group ID') - } - txnArgs.push(argValue) - continue - } - - if (isTransactionWithSigner(argValue)) { - throw new Error(`Expected non-transaction value for argument at index ${i}`) - } - - if (abiTypeIsReference(argType)) { - refArgIndexToBasicArgIndex.set(refArgTypes.length, basicArgTypes.length) - refArgTypes.push(argType) - refArgValues.push(argValue) - // treat the reference as a uint8 for encoding purposes - argType = new ABIUintType(8) - } - - if (typeof argType === 'string') { - throw new Error(`Unknown ABI type: ${argType}`) - } - - basicArgTypes.push(argType) - basicArgValues.push(argValue) - } - - const resolvedRefIndexes: number[] = [] - // Converting addresses to string form for easier comparison - const foreignAccounts: string[] = appAccounts == null ? [] : appAccounts.map((addr) => addr.toString()) - const foreignApps: bigint[] = appForeignApps == null ? [] : appForeignApps.map(ensureUint64) - const foreignAssets: bigint[] = appForeignAssets == null ? [] : appForeignAssets.map(ensureUint64) - for (let i = 0; i < refArgTypes.length; i++) { - const refType = refArgTypes[i] - const refValue = refArgValues[i] - let resolved = 0 - - switch (refType) { - case ABIReferenceType.account: { - const addressType = new ABIAddressType() - const address = addressType.decode(addressType.encode(refValue)) - resolved = populateForeignArray(address, foreignAccounts, sender.toString()) - break - } - case ABIReferenceType.application: { - const uint64Type = new ABIUintType(64) - const refAppID = uint64Type.decode(uint64Type.encode(refValue)) - if (refAppID > Number.MAX_SAFE_INTEGER) { - throw new Error(`Expected safe integer for application value, got ${refAppID}`) - } - resolved = populateForeignArray(refAppID, foreignApps, ensureUint64(appID)) - break - } - case ABIReferenceType.asset: { - const uint64Type = new ABIUintType(64) - const refAssetID = uint64Type.decode(uint64Type.encode(refValue)) - if (refAssetID > Number.MAX_SAFE_INTEGER) { - throw new Error(`Expected safe integer for asset value, got ${refAssetID}`) - } - resolved = populateForeignArray(refAssetID, foreignAssets) - break - } - default: - throw new Error(`Unknown reference type: ${refType}`) - } - - resolvedRefIndexes.push(resolved) - } - - for (let i = 0; i < resolvedRefIndexes.length; i++) { - const basicArgIndex = refArgIndexToBasicArgIndex.get(i)! - basicArgValues[basicArgIndex] = resolvedRefIndexes[i] - } - - if (basicArgTypes.length > MAX_APP_ARGS - 1) { - const lastArgTupleTypes = basicArgTypes.slice(MAX_APP_ARGS - 2) - const lastArgTupleValues = basicArgValues.slice(MAX_APP_ARGS - 2) - - basicArgTypes = basicArgTypes.slice(0, MAX_APP_ARGS - 2) - basicArgValues = basicArgValues.slice(0, MAX_APP_ARGS - 2) - - basicArgTypes.push(new ABITupleType(lastArgTupleTypes)) - basicArgValues.push(lastArgTupleValues) - } - - const appArgsEncoded: Uint8Array[] = [method.getSelector()] - for (let i = 0; i < basicArgTypes.length; i++) { - appArgsEncoded.push(basicArgTypes[i].encode(basicArgValues[i])) - } - - const appCall = { - txn: makeApplicationCallTxnFromObject({ - sender, - appIndex: appID, - appArgs: appArgsEncoded, - // Only pass legacy foreign arrays if access is not provided - accounts: access ? undefined : foreignAccounts, - foreignApps: access ? undefined : foreignApps, - foreignAssets: access ? undefined : foreignAssets, - boxes: access ? undefined : boxReferences, - access, - onComplete: onComplete == null ? OnApplicationComplete.NoOp : onComplete, - approvalProgram, - clearProgram, - numGlobalInts, - numGlobalByteSlices, - numLocalInts, - numLocalByteSlices, - extraPages, - rejectVersion, - lease, - note, - rekeyTo, - suggestedParams, - }), - signer, - } - - this.transactions.push(...txnArgs, appCall) - this.methodCalls.set(this.transactions.length - 1, method) - } - - /** - * Finalize the transaction group and returned the finalized transactions. - * - * The composer's status will be at least BUILT after executing this method. - */ - buildGroup(): TransactionWithSigner[] { - if (this.status === AtomicTransactionComposerStatus.BUILDING) { - if (this.transactions.length === 0) { - throw new Error('Cannot build a group with 0 transactions') - } - if (this.transactions.length > 1) { - const groupedTxns = assignGroupID(this.transactions.map((txnWithSigner) => txnWithSigner.txn)) - this.transactions = this.transactions.map((txnWithSigner, index) => ({ - signer: txnWithSigner.signer, - txn: groupedTxns[index], - })) - } - this.status = AtomicTransactionComposerStatus.BUILT - } - return this.transactions - } - - /** - * Obtain signatures for each transaction in this group. If signatures have already been obtained, - * this method will return cached versions of the signatures. - * - * The composer's status will be at least SIGNED after executing this method. - * - * An error will be thrown if signing any of the transactions fails. - * - * @returns A promise that resolves to an array of signed transactions. - */ - async gatherSignatures(): Promise { - if (this.status >= AtomicTransactionComposerStatus.SIGNED) { - return this.signedTxns - } - - // retrieve built transactions and verify status is BUILT - const txnsWithSigners = this.buildGroup() - const txnGroup = txnsWithSigners.map((txnWithSigner) => txnWithSigner.txn) - - const indexesPerSigner: Map = new Map() - - for (let i = 0; i < txnsWithSigners.length; i++) { - const { signer } = txnsWithSigners[i] - - if (!indexesPerSigner.has(signer)) { - indexesPerSigner.set(signer, []) - } - - indexesPerSigner.get(signer)!.push(i) - } - - const orderedSigners = Array.from(indexesPerSigner) - - const batchedSigs = await Promise.all(orderedSigners.map(([signer, indexes]) => signer(txnGroup, indexes))) - - const signedTxns: Array = txnsWithSigners.map(() => null) - - for (let signerIndex = 0; signerIndex < orderedSigners.length; signerIndex++) { - const indexes = orderedSigners[signerIndex][1] - const sigs = batchedSigs[signerIndex] - - for (let i = 0; i < indexes.length; i++) { - signedTxns[indexes[i]] = sigs[i] - } - } - - function fullyPopulated(a: Array): a is Uint8Array[] { - return a.every((v) => v != null) - } - - if (!fullyPopulated(signedTxns)) { - throw new Error(`Missing signatures. Got ${signedTxns}`) - } - - const txIDs = signedTxns.map((stxn, index) => { - try { - return getTransactionId(decodeSignedTransaction(stxn).txn) - } catch (err) { - throw new Error(`Cannot decode signed transaction at index ${index}. ${err}`) - } - }) - - this.signedTxns = signedTxns - this.txIDs = txIDs - this.status = AtomicTransactionComposerStatus.SIGNED - - return signedTxns - } - - /** - * Send the transaction group to the network, but don't wait for it to be committed to a block. An - * error will be thrown if submission fails. - * - * The composer's status must be SUBMITTED or lower before calling this method. If submission is - * successful, this composer's status will update to SUBMITTED. - * - * Note: a group can only be submitted again if it fails. - * - * @param client - An Algodv2 client - * - * @returns A promise that, upon success, resolves to a list of TxIDs of the submitted transactions. - */ - async submit(client: AlgodClient): Promise { - if (this.status > AtomicTransactionComposerStatus.SUBMITTED) { - throw new Error('Transaction group cannot be resubmitted') - } - - const stxns = await this.gatherSignatures() - - await client.sendRawTransaction(stxns) - - this.status = AtomicTransactionComposerStatus.SUBMITTED - - return this.txIDs - } - - /** - * Simulates the transaction group in the network. - * - * The composer will try to sign any transactions in the group, then simulate - * the results. - * Simulating the group will not change the composer's status. - * - * @param client - An Algodv2 client - * @param request - SimulateRequest with options in simulation. - * If provided, the request's transaction group will be overrwritten by the composer's group, - * only simulation related options will be used. - * - * @returns A promise that, upon success, resolves to an object containing an - * array of results containing one element for each method call transaction - * in this group (ABIResult[]) and the SimulateTransaction object. - */ - async simulate( - client: AlgodClient, - request?: SimulateRequest, - ): Promise<{ - methodResults: ABIResult[] - simulateResponse: SimulateTransaction - }> { - if (this.status > AtomicTransactionComposerStatus.SUBMITTED) { - throw new Error('Simulated Transaction group has already been submitted to the network') - } - - const stxns = await this.gatherSignatures() - const txnObjects: SignedTransaction[] = stxns.map((stxn) => decodeSignedTransaction(stxn)) - - const currentRequest: SimulateRequest = request ?? { txnGroups: [] } - - currentRequest.txnGroups = [ - { - txns: txnObjects, - }, - ] - - const simulateResponse = await client.simulateTransaction(currentRequest) - - // Parse method response - const methodResults: ABIResult[] = [] - for (const [txnIndex, method] of this.methodCalls) { - const txID = this.txIDs[txnIndex] - const pendingInfo = simulateResponse.txnGroups[0].txnResults[txnIndex].txnResult - - const methodResult: ABIResult = { - txID, - rawReturnValue: new Uint8Array(), - method, - } - - methodResults.push(AtomicTransactionComposer.parseMethodResponse(method, methodResult, pendingInfo)) - } - - return { methodResults, simulateResponse } - } - - /** - * Send the transaction group to the network and wait until it's committed to a block. An error - * will be thrown if submission or execution fails. - * - * The composer's status must be SUBMITTED or lower before calling this method, since execution is - * only allowed once. If submission is successful, this composer's status will update to SUBMITTED. - * If the execution is also successful, this composer's status will update to COMMITTED. - * - * Note: a group can only be submitted again if it fails. - * - * @param client - An Algodv2 client - * @param waitRounds - The maximum number of rounds to wait for transaction confirmation - * - * @returns A promise that, upon success, resolves to an object containing the confirmed round for - * this transaction, the txIDs of the submitted transactions, and an array of results containing - * one element for each method call transaction in this group. - */ - async execute( - client: AlgodClient, - waitRounds: number, - ): Promise<{ - confirmedRound: bigint - txIDs: string[] - methodResults: ABIResult[] - }> { - if (this.status === AtomicTransactionComposerStatus.COMMITTED) { - throw new Error('Transaction group has already been executed successfully') - } - - const txIDs = await this.submit(client) - this.status = AtomicTransactionComposerStatus.SUBMITTED - - const firstMethodCallIndex = this.transactions.findIndex((_, index) => this.methodCalls.has(index)) - const indexToWaitFor = firstMethodCallIndex === -1 ? 0 : firstMethodCallIndex - const confirmedTxnInfo = await waitForConfirmation(client, txIDs[indexToWaitFor], waitRounds) - this.status = AtomicTransactionComposerStatus.COMMITTED - - const confirmedRound = confirmedTxnInfo.confirmedRound! - - const methodResults: ABIResult[] = [] - - for (const [txnIndex, method] of this.methodCalls) { - const txID = txIDs[txnIndex] - - let methodResult: ABIResult = { - txID, - rawReturnValue: new Uint8Array(), - method, - } - - try { - const pendingInfo = txnIndex === firstMethodCallIndex ? confirmedTxnInfo : await client.pendingTransactionInformation(txID) - - methodResult = AtomicTransactionComposer.parseMethodResponse(method, methodResult, pendingInfo) - } catch (err) { - methodResult.decodeError = err as Error - } - - methodResults.push(methodResult) - } - - return { - confirmedRound, - txIDs, - methodResults, - } - } - - /** - * Parses a single ABI Method transaction log into a ABI result object. - * - * @param method - * @param methodResult - * @param pendingInfo - * @returns An ABIResult object - */ - static parseMethodResponse(method: ABIMethod, methodResult: ABIResult, pendingInfo: PendingTransactionResponse): ABIResult { - const returnedResult: ABIResult = methodResult - try { - returnedResult.txInfo = pendingInfo - if (method.returns.type !== 'void') { - const logs = pendingInfo.logs || [] - if (logs.length === 0) { - throw new Error(`App call transaction did not log a return value ${stringifyJSON(pendingInfo)}`) - } - const lastLog = logs[logs.length - 1] - if (lastLog.byteLength < 4 || !arrayEqual(lastLog.slice(0, 4), RETURN_PREFIX)) { - throw new Error(`App call transaction did not log a ABI return value ${stringifyJSON(pendingInfo)}`) - } - - returnedResult.rawReturnValue = new Uint8Array(lastLog.slice(4)) - returnedResult.returnValue = method.returns.type.decode(methodResult.rawReturnValue) - } - } catch (err) { - returnedResult.decodeError = err as Error - } - - return returnedResult - } -} diff --git a/packages/sdk/src/encoding/schema/binarystring.ts b/packages/sdk/src/encoding/schema/binarystring.ts deleted file mode 100644 index 01308732..00000000 --- a/packages/sdk/src/encoding/schema/binarystring.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { RawBinaryString } from 'algorand-msgpack'; -import { - Schema, - MsgpackEncodingData, - MsgpackRawStringProvider, - JSONEncodingData, - PrepareJSONOptions, -} from '../encoding.js'; -import { coerceToBytes, bytesToString, bytesToBase64 } from '../binarydata.js'; -import { arrayEqual } from '../../utils/utils.js'; - -/* eslint-disable class-methods-use-this */ - -/** - * SpecialCaseBinaryStringSchema is a schema for byte arrays which are encoded - * as strings in msgpack and JSON. - * - * This schema allows lossless conversion between the in memory representation - * and the msgpack encoded representation, but NOT between the in memory and - * JSON encoded representations if the byte array contains invalid UTF-8 - * sequences. - */ -export class SpecialCaseBinaryStringSchema extends Schema { - public defaultValue(): Uint8Array { - return new Uint8Array(); - } - - public isDefaultValue(data: unknown): boolean { - return data instanceof Uint8Array && data.byteLength === 0; - } - - public prepareMsgpack(data: unknown): MsgpackEncodingData { - if (data instanceof Uint8Array) { - // Cast is needed because RawBinaryString is not part of the standard MsgpackEncodingData - return new RawBinaryString(data) as unknown as MsgpackEncodingData; - } - throw new Error(`Invalid byte array: (${typeof data}) ${data}`); - } - - public fromPreparedMsgpack( - _encoded: MsgpackEncodingData, - rawStringProvider: MsgpackRawStringProvider - ): Uint8Array { - return rawStringProvider.getRawStringAtCurrentLocation(); - } - - public prepareJSON( - data: unknown, - options: PrepareJSONOptions - ): JSONEncodingData { - if (data instanceof Uint8Array) { - // Not safe to convert to string for all binary data - const stringValue = bytesToString(data); - if ( - !options.lossyBinaryStringConversion && - !arrayEqual(coerceToBytes(stringValue), data) - ) { - throw new Error( - `Invalid UTF-8 byte array encountered. Encode with lossyBinaryStringConversion enabled to bypass this check. Base64 value: ${bytesToBase64(data)}` - ); - } - return stringValue; - } - throw new Error(`Invalid byte array: (${typeof data}) ${data}`); - } - - public fromPreparedJSON(encoded: JSONEncodingData): Uint8Array { - if (typeof encoded === 'string') { - return coerceToBytes(encoded); - } - throw new Error(`Invalid byte array: (${typeof encoded}) ${encoded}`); - } -} diff --git a/packages/sdk/src/encoding/schema/index.ts b/packages/sdk/src/encoding/schema/index.ts index 04e5476b..c52742b1 100644 --- a/packages/sdk/src/encoding/schema/index.ts +++ b/packages/sdk/src/encoding/schema/index.ts @@ -7,8 +7,6 @@ export { ByteArraySchema, FixedLengthByteArraySchema } from './bytearray.js' export { BlockHashSchema } from './blockhash.js' -export { SpecialCaseBinaryStringSchema } from './binarystring.js' - export { ArraySchema } from './array.js' export * from './map.js' export { OptionalSchema } from './optional.js' diff --git a/packages/sdk/src/group.ts b/packages/sdk/src/group.ts deleted file mode 100644 index 23060dd6..00000000 --- a/packages/sdk/src/group.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Transaction } from '@algorandfoundation/algokit-transact' -import { getTransactionIdRaw, groupTransactions as groupTxns } from '@algorandfoundation/algokit-transact' -import { msgpackRawEncode } from './encoding/encoding.js' -import * as nacl from './nacl/naclWrappers.js' -import * as utils from './utils/utils.js' - -const ALGORAND_MAX_TX_GROUP_SIZE = 16 -const TX_GROUP_TAG = new TextEncoder().encode('TG') - -function txGroupPreimage(txnHashes: Uint8Array[]): Uint8Array { - if (txnHashes.length > ALGORAND_MAX_TX_GROUP_SIZE) { - throw new Error(`${txnHashes.length} transactions grouped together but max group size is ${ALGORAND_MAX_TX_GROUP_SIZE}`) - } - if (txnHashes.length === 0) { - throw new Error('Cannot compute group ID of zero transactions') - } - const bytes = msgpackRawEncode({ - txlist: txnHashes, - }) - return utils.concatArrays(TX_GROUP_TAG, bytes) -} - -/** - * computeGroupID returns group ID for a group of transactions - * @param txns - array of transactions - * @returns Uint8Array - */ -export function computeGroupID(txns: ReadonlyArray): Uint8Array { - const hashes: Uint8Array[] = [] - for (const txn of txns) { - hashes.push(getTransactionIdRaw(txn)) - } - - const toBeHashed = txGroupPreimage(hashes) - const gid = nacl.genericHash(toBeHashed) - return Uint8Array.from(gid) -} - -/** - * assignGroupID assigns group id to a given list of unsigned transactions - * @param txns - array of transactions. Returns a new array with group IDs assigned (immutable) - * @returns Transaction[] - New array of transactions with group IDs assigned - */ -export function assignGroupID(txns: Transaction[]): Transaction[] { - // Mutate the transaction to keep the existing algosdk behaviour - const groupedTxn = groupTxns(txns) - txns.forEach((txn, i) => { - txn.group = groupedTxn[i].group - }) - return txns -} diff --git a/packages/sdk/src/heartbeat.ts b/packages/sdk/src/heartbeat.ts deleted file mode 100644 index 90404473..00000000 --- a/packages/sdk/src/heartbeat.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Address } from './encoding/address.js'; -import { Encodable, Schema } from './encoding/encoding.js'; -import { - AddressSchema, - Uint64Schema, - ByteArraySchema, - FixedLengthByteArraySchema, - NamedMapSchema, - allOmitEmpty, -} from './encoding/schema/index.js'; - -export class HeartbeatProof implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { - key: 's', // Sig - valueSchema: new FixedLengthByteArraySchema(64), - }, - { - key: 'p', // PK - valueSchema: new FixedLengthByteArraySchema(32), - }, - { - key: 'p2', // PK2 - valueSchema: new FixedLengthByteArraySchema(32), - }, - { - key: 'p1s', // PK1Sig - valueSchema: new FixedLengthByteArraySchema(64), - }, - { - key: 'p2s', // PK2Sig - valueSchema: new FixedLengthByteArraySchema(64), - }, - ]) - ); - - public sig: Uint8Array; - - public pk: Uint8Array; - - public pk2: Uint8Array; - - public pk1Sig: Uint8Array; - - public pk2Sig: Uint8Array; - - public constructor(params: { - sig: Uint8Array; - pk: Uint8Array; - pk2: Uint8Array; - pk1Sig: Uint8Array; - pk2Sig: Uint8Array; - }) { - this.sig = params.sig; - this.pk = params.pk; - this.pk2 = params.pk2; - this.pk1Sig = params.pk1Sig; - this.pk2Sig = params.pk2Sig; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return HeartbeatProof.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['s', this.sig], - ['p', this.pk], - ['p2', this.pk2], - ['p1s', this.pk1Sig], - ['p2s', this.pk2Sig], - ]); - } - - public static fromEncodingData(data: unknown): HeartbeatProof { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded HeartbeatProof: ${data}`); - } - return new HeartbeatProof({ - sig: data.get('s'), - pk: data.get('p'), - pk2: data.get('p2'), - pk1Sig: data.get('p1s'), - pk2Sig: data.get('p2s'), - }); - } -} - -export class Heartbeat implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { - key: 'a', // HbAddress - valueSchema: new AddressSchema(), - }, - { - key: 'prf', // HbProof - valueSchema: HeartbeatProof.encodingSchema, - }, - { - key: 'sd', // HbSeed - valueSchema: new ByteArraySchema(), - }, - { - key: 'vid', // HbVoteID - valueSchema: new FixedLengthByteArraySchema(32), - }, - { - key: 'kd', // HbKeyDilution - valueSchema: new Uint64Schema(), - }, - ]) - ); - - public address: Address; - - public proof: HeartbeatProof; - - public seed: Uint8Array; - - public voteID: Uint8Array; - - public keyDilution: bigint; - - public constructor(params: { - address: Address; - proof: HeartbeatProof; - seed: Uint8Array; - voteID: Uint8Array; - keyDilution: bigint; - }) { - this.address = params.address; - this.proof = params.proof; - this.seed = params.seed; - this.voteID = params.voteID; - this.keyDilution = params.keyDilution; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return Heartbeat.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['a', this.address], - ['prf', this.proof.toEncodingData()], - ['sd', this.seed], - ['vid', this.voteID], - ['kd', this.keyDilution], - ]); - } - - public static fromEncodingData(data: unknown): Heartbeat { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded Heartbeat: ${data}`); - } - return new Heartbeat({ - address: data.get('a'), - proof: HeartbeatProof.fromEncodingData(data.get('prf')), - seed: data.get('sd'), - voteID: data.get('vid'), - keyDilution: data.get('kd'), - }); - } -} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 01b8996b..3886cd37 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -85,10 +85,8 @@ export { bigIntToBytes, bytesToBigInt } from './encoding/bigint' export { base64ToBytes, bytesToBase64, bytesToHex, bytesToString, coerceToBytes, hexToBytes } from './encoding/binarydata' export * from './encoding/encoding' export { decodeUint64, encodeUint64 } from './encoding/uint64' -export { assignGroupID, computeGroupID } from './group' export * from './logic/sourcemap' export * from './logicsig' -export * from './makeTxn' export { masterDerivationKeyToMnemonic, mnemonicFromSeed, @@ -100,11 +98,9 @@ export { export * from './multisig' export * from './signer' export { signLogicSigTransaction, signLogicSigTransactionObject } from './signing' -export * from './stateproof' export * from './types/account' export type { default as Account } from './types/account' export * from './types/intDecoding' export { default as IntDecoding } from './types/intDecoding' export * from './types/transactions/index' export * from './utils/utils' -export { waitForConfirmation } from './wait' diff --git a/packages/sdk/src/makeTxn.ts b/packages/sdk/src/makeTxn.ts deleted file mode 100644 index 1cab14a2..00000000 --- a/packages/sdk/src/makeTxn.ts +++ /dev/null @@ -1,941 +0,0 @@ -import { SuggestedParams } from '@algorandfoundation/algokit-algod-client' -import type { Transaction } from '@algorandfoundation/algokit-transact' -import { OnApplicationComplete, TransactionType } from '@algorandfoundation/algokit-transact' -import { foreignArraysToResourceReferences } from './appAccess.js' -import { Address } from './encoding/address.js' -import { - ApplicationCallReferenceParams, - ApplicationCallTransactionParams, - AssetConfigurationTransactionParams, - AssetFreezeTransactionParams, - AssetTransferTransactionParams, - KeyRegistrationTransactionParams, - PaymentTransactionParams, -} from './types/transactions/base.js' - -// Helper function to convert Address to string -function addressToString(addr: string | Address | undefined): string | undefined { - if (!addr) return undefined - return typeof addr === 'string' ? addr : addr.toString() -} - -// Helper function to ensure bigint -function ensureBigInt(value: number | bigint | undefined): bigint | undefined { - if (value === undefined) return undefined - return typeof value === 'bigint' ? value : BigInt(value) -} - -/** Contains parameters common to every transaction type */ -export interface CommonTransactionParams { - /** Algorand address of sender */ - sender: string | Address - /** Suggested parameters relevant to the network that will accept this transaction */ - suggestedParams: SuggestedParams - /** Optional, arbitrary data to be stored in the transaction's note field */ - note?: Uint8Array - /** - * Optional, 32-byte lease to associate with this transaction. - * - * The sender cannot send another transaction with the same lease until the last round of original - * transaction has passed. - */ - lease?: Uint8Array - /** The Algorand address that will be used to authorize all future transactions from the sender, if provided. */ - rekeyTo?: string | Address -} - -/** - * Create a new payment transaction - * - * @param options - Payment transaction parameters - */ -export function makePaymentTxnWithSuggestedParamsFromObject({ - sender, - receiver, - amount, - closeRemainderTo, - suggestedParams, - note, - lease, - rekeyTo, -}: PaymentTransactionParams & CommonTransactionParams): Transaction { - const txn: Transaction = { - type: TransactionType.Payment, - sender: addressToString(sender)!, - firstValid: BigInt(suggestedParams.firstValid), - lastValid: BigInt(suggestedParams.lastValid), - genesisHash: suggestedParams.genesisHash, - genesisId: suggestedParams.genesisId, - note, - lease, - rekeyTo: addressToString(rekeyTo), - payment: { - receiver: addressToString(receiver)!, - amount: ensureBigInt(amount)!, - closeRemainderTo: addressToString(closeRemainderTo), - }, - } - - return txn -} - -/** - * Create a new key registration transaction - * - * @param options - Key registration transaction parameters - */ -export function makeKeyRegistrationTxnWithSuggestedParamsFromObject({ - sender, - voteKey, - selectionKey, - stateProofKey, - voteFirst, - voteLast, - voteKeyDilution, - nonParticipation, - suggestedParams, - note, - lease, - rekeyTo, -}: KeyRegistrationTransactionParams & CommonTransactionParams): Transaction { - const txn: Transaction = { - type: TransactionType.KeyRegistration, - sender: addressToString(sender)!, - firstValid: BigInt(suggestedParams.firstValid), - lastValid: BigInt(suggestedParams.lastValid), - genesisHash: suggestedParams.genesisHash, - genesisId: suggestedParams.genesisId, - note, - lease, - rekeyTo: addressToString(rekeyTo), - keyRegistration: { - voteKey, - selectionKey, - stateProofKey, - voteFirst: ensureBigInt(voteFirst), - voteLast: ensureBigInt(voteLast), - voteKeyDilution: ensureBigInt(voteKeyDilution), - nonParticipation, - }, - } - - return txn -} - -/** - * Base function for creating any type of asset config transaction. - * - * @param options - Asset config transaction parameters - */ -export function makeBaseAssetConfigTxn({ - sender, - assetIndex, - total, - decimals, - defaultFrozen, - manager, - reserve, - freeze, - clawback, - unitName, - assetName, - assetURL, - assetMetadataHash, - note, - lease, - rekeyTo, - suggestedParams, -}: AssetConfigurationTransactionParams & CommonTransactionParams): Transaction { - const txn: Transaction = { - type: TransactionType.AssetConfig, - sender: addressToString(sender)!, - firstValid: BigInt(suggestedParams.firstValid), - lastValid: BigInt(suggestedParams.lastValid), - genesisHash: suggestedParams.genesisHash, - genesisId: suggestedParams.genesisId, - note, - lease, - rekeyTo: addressToString(rekeyTo), - assetConfig: { - assetId: ensureBigInt(assetIndex)!, - total: ensureBigInt(total), - decimals: typeof decimals === 'number' ? decimals : undefined, - defaultFrozen, - manager: addressToString(manager), - reserve: addressToString(reserve), - freeze: addressToString(freeze), - clawback: addressToString(clawback), - unitName, - assetName, - url: assetURL, - metadataHash: assetMetadataHash, - }, - } - - return txn -} - -/** - * Create a new asset creation transaction - * - * @param options - Asset creation transaction parameters - */ -export function makeAssetCreateTxnWithSuggestedParamsFromObject({ - sender, - total, - decimals, - defaultFrozen, - manager, - reserve, - freeze, - clawback, - unitName, - assetName, - assetURL, - assetMetadataHash, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit & CommonTransactionParams): Transaction { - return makeBaseAssetConfigTxn({ - sender, - assetIndex: 0, - total, - decimals, - defaultFrozen, - manager, - reserve, - freeze, - clawback, - unitName, - assetName, - assetURL, - assetMetadataHash, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** Contains asset modification transaction parameters */ -export interface AssetModificationTransactionParams { - /** - * The unique ID of the asset to be modified - */ - assetIndex: number | bigint - - /** - * The Algorand address in charge of reserve, freeze, clawback, destruction, etc. - * - * If empty, this role will be irrevocably removed from this asset. - */ - manager?: string | Address - - /** - * The Algorand address representing asset reserve. - * - * If empty, this role will be irrevocably removed from this asset. - */ - reserve?: string | Address - - /** - * The Algorand address with power to freeze/unfreeze asset holdings. - * - * If empty, this role will be irrevocably removed from this asset. - */ - freeze?: string | Address - - /** - * The Algorand address with power to revoke asset holdings. - * - * If empty, this role will be irrevocably removed from this asset. - */ - clawback?: string | Address - - /** - * This is a safety flag to prevent unintentionally removing a role from an asset. If undefined or - * true, an error will be thrown if any of assetManager, assetReserve, assetFreeze, or - * assetClawback are empty. - * - * Set this to false to allow removing roles by leaving the corresponding address empty. - */ - strictEmptyAddressChecking?: boolean -} - -/** - * Create a new asset config transaction. This transaction can be issued by the asset manager to - * change the manager, reserve, freeze, or clawback address. - * - * You must respecify existing addresses to keep them the same; leaving a field blank is the same as - * turning that feature off for this asset. - * - * @param options - Asset modification transaction parameters - */ -export function makeAssetConfigTxnWithSuggestedParamsFromObject({ - sender, - assetIndex, - manager, - reserve, - freeze, - clawback, - strictEmptyAddressChecking, - note, - lease, - rekeyTo, - suggestedParams, -}: AssetModificationTransactionParams & CommonTransactionParams): Transaction { - if (!assetIndex) { - throw Error('assetIndex must be provided') - } - const strictChecking = strictEmptyAddressChecking ?? true - if (strictChecking && (manager == null || reserve == null || freeze == null || clawback == null)) { - throw Error( - 'strictEmptyAddressChecking is enabled, but an address is empty. If this is intentional, set strictEmptyAddressChecking to false.', - ) - } - return makeBaseAssetConfigTxn({ - sender, - assetIndex, - manager, - reserve, - freeze, - clawback, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Create a new asset destroy transaction. This will allow the asset's manager to remove this asset - * from the ledger, provided all outstanding assets are held by the creator. - * - * @param options - Asset destroy transaction parameters - */ -export function makeAssetDestroyTxnWithSuggestedParamsFromObject({ - sender, - assetIndex, - note, - lease, - rekeyTo, - suggestedParams, -}: Required> & CommonTransactionParams): Transaction { - if (!assetIndex) { - throw Error('assetIndex must be provided') - } - return makeBaseAssetConfigTxn({ - sender, - assetIndex, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Create a new asset freeze transaction. This transaction allows the asset's freeze manager to - * freeze or un-freeze an account, blocking or allowing asset transfers to and from the targeted - * account. - * - * @param options - Asset freeze transaction parameters - */ -export function makeAssetFreezeTxnWithSuggestedParamsFromObject({ - sender, - assetIndex, - freezeTarget, - frozen, - suggestedParams, - note, - lease, - rekeyTo, -}: AssetFreezeTransactionParams & CommonTransactionParams): Transaction { - const txn: Transaction = { - type: TransactionType.AssetFreeze, - sender: addressToString(sender)!, - firstValid: BigInt(suggestedParams.firstValid), - lastValid: BigInt(suggestedParams.lastValid), - genesisHash: suggestedParams.genesisHash, - genesisId: suggestedParams.genesisId, - note, - lease, - rekeyTo: addressToString(rekeyTo), - assetFreeze: { - assetId: ensureBigInt(assetIndex)!, - freezeTarget: addressToString(freezeTarget)!, - frozen, - }, - } - - return txn -} - -/** - * Create a new asset transfer transaction. - * - * Special case: to opt into an assets, set amount=0 and sender=receiver. - * - * @param options - Asset transfer transaction parameters - */ -export function makeAssetTransferTxnWithSuggestedParamsFromObject({ - sender, - receiver, - amount, - closeRemainderTo, - assetSender, - note, - assetIndex, - suggestedParams, - rekeyTo, - lease, -}: AssetTransferTransactionParams & CommonTransactionParams): Transaction { - if (!assetIndex) { - throw Error('assetIndex must be provided') - } - - const txn: Transaction = { - type: TransactionType.AssetTransfer, - sender: addressToString(sender)!, - firstValid: BigInt(suggestedParams.firstValid), - lastValid: BigInt(suggestedParams.lastValid), - genesisHash: suggestedParams.genesisHash, - genesisId: suggestedParams.genesisId, - note, - lease, - rekeyTo: addressToString(rekeyTo), - assetTransfer: { - assetId: ensureBigInt(assetIndex)!, - receiver: addressToString(receiver)!, - amount: ensureBigInt(amount)!, - assetSender: addressToString(assetSender), - closeRemainderTo: addressToString(closeRemainderTo), - }, - } - - return txn -} - -/** - * Base function for creating any application call transaction. - * - * @param options - Application call transaction parameters - */ -export function makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - approvalProgram, - clearProgram, - numLocalInts, - numLocalByteSlices, - numGlobalInts, - numGlobalByteSlices, - extraPages, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - rejectVersion, // TODO: handle reject version - note, - lease, - rekeyTo, - suggestedParams, -}: ApplicationCallTransactionParams & CommonTransactionParams & ApplicationCallReferenceParams): Transaction { - if (onComplete == null) { - throw Error('onComplete must be provided') - } - if (access && (accounts || foreignApps || foreignAssets || boxes || holdings || locals)) { - throw Error('cannot specify both access and other access fields') - } - let access2 = access - if (convertToAccess) { - access2 = foreignArraysToResourceReferences({ - appIndex, - accounts, - foreignApps, - foreignAssets, - holdings, - locals, - boxes, - }) - } - - // Convert legacy foreign arrays to new format if access is not provided - const accountReferences = access2 ? undefined : accounts?.map((a) => addressToString(a)!) - const appReferences = access2 ? undefined : foreignApps?.map((a) => ensureBigInt(a)!) - const assetReferences = access2 ? undefined : foreignAssets?.map((a) => ensureBigInt(a)!) - - // Convert boxes if present (boxes have app index and name) - const boxReferences = access2 - ? undefined - : boxes?.map((box) => ({ - appId: ensureBigInt(box.appId) || BigInt(0), - name: box.name, - })) - - const txn: Transaction = { - type: TransactionType.AppCall, - sender: addressToString(sender)!, - firstValid: BigInt(suggestedParams.firstValid), - lastValid: BigInt(suggestedParams.lastValid), - genesisHash: suggestedParams.genesisHash, - genesisId: suggestedParams.genesisId, - note, - lease, - rekeyTo: addressToString(rekeyTo), - appCall: { - appId: ensureBigInt(appIndex) || BigInt(0), - onComplete, - approvalProgram, - clearStateProgram: clearProgram, - globalStateSchema: - numGlobalInts !== undefined || numGlobalByteSlices !== undefined - ? { - numUints: Number(numGlobalInts) || 0, - numByteSlices: Number(numGlobalByteSlices) || 0, - } - : undefined, - localStateSchema: - numLocalInts !== undefined || numLocalByteSlices !== undefined - ? { - numUints: Number(numLocalInts) || 0, - numByteSlices: Number(numLocalByteSlices) || 0, - } - : undefined, - extraProgramPages: extraPages !== undefined ? Number(extraPages) : undefined, - args: appArgs, - // Only pass legacy foreign arrays if access is not provided - accountReferences: access2 ? undefined : accountReferences, - assetReferences: access2 ? undefined : assetReferences, - appReferences: access2 ? undefined : appReferences, - boxReferences: access2 ? undefined : boxReferences, - accessReferences: access2, - }, - } - - return txn -} - -/** - * Make a transaction that will create an application. - * - * @param options - Application creation transaction parameters - */ -export function makeApplicationCreateTxnFromObject({ - sender, - onComplete, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - approvalProgram, - clearProgram, - numLocalInts, - numLocalByteSlices, - numGlobalInts, - numGlobalByteSlices, - extraPages, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit & - Required> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!approvalProgram || !clearProgram) { - throw Error('approvalProgram and clearProgram must be provided') - } - if (onComplete == null) { - throw Error('onComplete must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex: 0, - onComplete, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - approvalProgram, - clearProgram, - numLocalInts, - numLocalByteSlices, - numGlobalInts, - numGlobalByteSlices, - extraPages, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Make a transaction that changes an application's approval and clear programs - * - * @param options - Application update transaction parameters - */ -export function makeApplicationUpdateTxnFromObject({ - sender, - appIndex, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - approvalProgram, - clearProgram, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit< - ApplicationCallTransactionParams, - | 'onComplete' - | 'numLocalInts' - | 'numLocalByteSlices' - | 'numGlobalInts' - | 'numGlobalByteSlices' - | 'extraPages' - | 'approvalProgram' - | 'clearProgram' -> & - Required> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!appIndex) { - throw Error('appIndex must be provided') - } - if (!approvalProgram || !clearProgram) { - throw Error('approvalProgram and clearProgram must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete: OnApplicationComplete.UpdateApplication, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - approvalProgram, - clearProgram, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Make a transaction that deletes an application - * - * @param options - Application deletion transaction parameters - */ -export function makeApplicationDeleteTxnFromObject({ - sender, - appIndex, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit< - ApplicationCallTransactionParams, - | 'onComplete' - | 'numLocalInts' - | 'numLocalByteSlices' - | 'numGlobalInts' - | 'numGlobalByteSlices' - | 'extraPages' - | 'approvalProgram' - | 'clearProgram' -> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!appIndex) { - throw Error('appIndex must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete: OnApplicationComplete.DeleteApplication, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Make a transaction that opts in to use an application - * - * @param options - Application opt-in transaction parameters - */ -export function makeApplicationOptInTxnFromObject({ - sender, - appIndex, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit< - ApplicationCallTransactionParams, - | 'onComplete' - | 'numLocalInts' - | 'numLocalByteSlices' - | 'numGlobalInts' - | 'numGlobalByteSlices' - | 'extraPages' - | 'approvalProgram' - | 'clearProgram' -> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!appIndex) { - throw Error('appIndex must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete: OnApplicationComplete.OptIn, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - note, - convertToAccess, - holdings, - locals, - access, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Make a transaction that closes out a user's state in an application - * - * @param options - Application close-out transaction parameters - */ -export function makeApplicationCloseOutTxnFromObject({ - sender, - appIndex, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit< - ApplicationCallTransactionParams, - | 'onComplete' - | 'numLocalInts' - | 'numLocalByteSlices' - | 'numGlobalInts' - | 'numGlobalByteSlices' - | 'extraPages' - | 'approvalProgram' - | 'clearProgram' -> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!appIndex) { - throw Error('appIndex must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete: OnApplicationComplete.CloseOut, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Make a transaction that clears a user's state in an application - * - * @param options - Application clear state transaction parameters - */ -export function makeApplicationClearStateTxnFromObject({ - sender, - appIndex, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit< - ApplicationCallTransactionParams, - | 'onComplete' - | 'numLocalInts' - | 'numLocalByteSlices' - | 'numGlobalInts' - | 'numGlobalByteSlices' - | 'extraPages' - | 'approvalProgram' - | 'clearProgram' -> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!appIndex) { - throw Error('appIndex must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete: OnApplicationComplete.ClearState, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, - }) -} - -/** - * Make a transaction that just calls an application, doing nothing on completion - * - * @param options - Application no-op transaction parameters - */ -export function makeApplicationNoOpTxnFromObject({ - sender, - appIndex, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, -}: Omit< - ApplicationCallTransactionParams, - | 'onComplete' - | 'numLocalInts' - | 'numLocalByteSlices' - | 'numGlobalInts' - | 'numGlobalByteSlices' - | 'extraPages' - | 'approvalProgram' - | 'clearProgram' -> & - CommonTransactionParams & - ApplicationCallReferenceParams): Transaction { - if (!appIndex) { - throw Error('appIndex must be provided') - } - return makeApplicationCallTxnFromObject({ - sender, - appIndex, - onComplete: OnApplicationComplete.NoOp, - appArgs, - accounts, - foreignApps, - foreignAssets, - boxes, - convertToAccess, - holdings, - locals, - access, - note, - lease, - rekeyTo, - suggestedParams, - }) -} diff --git a/packages/sdk/src/signer.ts b/packages/sdk/src/signer.ts index 15bdeba4..62bea625 100644 --- a/packages/sdk/src/signer.ts +++ b/packages/sdk/src/signer.ts @@ -109,21 +109,3 @@ export function makeEmptyTransactionSigner(): TransactionSigner { return Promise.resolve(unsigned) } } - -/** Represents an unsigned transactions and a signer that can authorize that transaction. */ -export interface TransactionWithSigner { - /** An unsigned transaction */ - txn: Transaction - /** A transaction signer that can authorize txn */ - signer: TransactionSigner -} - -/** - * Check if a value conforms to the TransactionWithSigner structure. - * @param value - The value to check. - * @returns True if an only if the value has the structure of a TransactionWithSigner. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isTransactionWithSigner(value: any): value is TransactionWithSigner { - return typeof value === 'object' && Object.keys(value).length === 2 && typeof value.txn === 'object' && typeof value.signer === 'function' -} diff --git a/packages/sdk/src/stateproof.ts b/packages/sdk/src/stateproof.ts deleted file mode 100644 index bf1e17d2..00000000 --- a/packages/sdk/src/stateproof.ts +++ /dev/null @@ -1,595 +0,0 @@ -import { Encodable, Schema } from './encoding/encoding.js'; -import { - Uint64Schema, - ByteArraySchema, - FixedLengthByteArraySchema, - ArraySchema, - NamedMapSchema, - Uint64MapSchema, - allOmitEmpty, - convertMap, -} from './encoding/schema/index.js'; - -export class HashFactory implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { key: 't', valueSchema: new Uint64Schema() }, // hashType - ]) - ); - - public hashType: number; - - public constructor(params: { hashType: number }) { - this.hashType = params.hashType; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return HashFactory.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([['t', this.hashType]]); - } - - public static fromEncodingData(data: unknown): HashFactory { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded HashFactory: ${data}`); - } - return new HashFactory({ - hashType: Number(data.get('t')), - }); - } -} - -export class MerkleArrayProof implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { - key: 'pth', // path - valueSchema: new ArraySchema(new ByteArraySchema()), - }, - { - key: 'hsh', // hashFactory - valueSchema: HashFactory.encodingSchema, - }, - { - key: 'td', // treeDepth - valueSchema: new Uint64Schema(), - }, - ]) - ); - - /** - * Path is bounded by MaxNumLeavesOnEncodedTree since there could be multiple reveals, and - * given the distribution of the elt positions and the depth of the tree, the path length can - * increase up to 2^MaxEncodedTreeDepth / 2 - */ - public path: Uint8Array[]; - - public hashFactory: HashFactory; - - /** - * TreeDepth represents the depth of the tree that is being proven. It is the number of edges - * from the root to a leaf. - */ - public treeDepth: number; - - public constructor(params: { - path: Uint8Array[]; - hashFactory: HashFactory; - treeDepth: number; - }) { - this.path = params.path; - this.hashFactory = params.hashFactory; - this.treeDepth = params.treeDepth; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return MerkleArrayProof.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['pth', this.path], - ['hsh', this.hashFactory.toEncodingData()], - ['td', this.treeDepth], - ]); - } - - public static fromEncodingData(data: unknown): MerkleArrayProof { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded MerkleArrayProof: ${data}`); - } - return new MerkleArrayProof({ - path: data.get('pth'), - hashFactory: HashFactory.fromEncodingData(data.get('hsh')), - treeDepth: Number(data.get('td')), - }); - } -} - -/** - * MerkleSignatureVerifier is used to verify a merkle signature. - */ -export class MerkleSignatureVerifier implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { - key: 'cmt', // commitment - valueSchema: new FixedLengthByteArraySchema(64), - }, - { - key: 'lf', // keyLifetime - valueSchema: new Uint64Schema(), - }, - ]) - ); - - public commitment: Uint8Array; - - public keyLifetime: bigint; - - public constructor(params: { commitment: Uint8Array; keyLifetime: bigint }) { - this.commitment = params.commitment; - this.keyLifetime = params.keyLifetime; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return MerkleSignatureVerifier.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['cmt', this.commitment], - ['lf', this.keyLifetime], - ]); - } - - public static fromEncodingData(data: unknown): MerkleSignatureVerifier { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded MerkleSignatureVerifier: ${data}`); - } - return new MerkleSignatureVerifier({ - commitment: data.get('cmt'), - keyLifetime: data.get('lf'), - }); - } -} - -/** - * A Participant corresponds to an account whose AccountData.Status is Online, and for which the - * expected sigRound satisfies AccountData.VoteFirstValid <= sigRound <= AccountData.VoteLastValid. - * - * In the Algorand ledger, it is possible for multiple accounts to have the same PK. Thus, the PK is - * not necessarily unique among Participants. However, each account will produce a unique Participant - * struct, to avoid potential DoS attacks where one account claims to have the same VoteID PK as - * another account. - */ -export class Participant implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { - key: 'p', // pk - valueSchema: MerkleSignatureVerifier.encodingSchema, - }, - { - key: 'w', // weight - valueSchema: new Uint64Schema(), - }, - ]) - ); - - /** - * pk is the identifier used to verify the signature for a specific participant - */ - public pk: MerkleSignatureVerifier; - - /** - * weight is AccountData.MicroAlgos. - */ - public weight: bigint; - - public constructor(params: { pk: MerkleSignatureVerifier; weight: bigint }) { - this.pk = params.pk; - this.weight = params.weight; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return Participant.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['p', this.pk.toEncodingData()], - ['w', this.weight], - ]); - } - - public static fromEncodingData(data: unknown): Participant { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded Participant: ${data}`); - } - return new Participant({ - pk: MerkleSignatureVerifier.fromEncodingData(data.get('p')), - weight: data.get('w'), - }); - } -} - -export class FalconVerifier implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { key: 'k', valueSchema: new FixedLengthByteArraySchema(0x701) }, // publicKey - ]) - ); - - public publicKey: Uint8Array; - - public constructor(params: { publicKey: Uint8Array }) { - this.publicKey = params.publicKey; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return FalconVerifier.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([['k', this.publicKey]]); - } - - public static fromEncodingData(data: unknown): FalconVerifier { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded FalconVerifier: ${data}`); - } - return new FalconVerifier({ - publicKey: data.get('k'), - }); - } -} - -/** - * FalconSignatureStruct represents a signature in the merkle signature scheme using falcon signatures - * as an underlying crypto scheme. It consists of an ephemeral public key, a signature, a merkle - * verification path and an index. The merkle signature considered valid only if the Signature is - * verified under the ephemeral public key and the Merkle verification path verifies that the - * ephemeral public key is located at the given index of the tree (for the root given in the - * long-term public key). More details can be found on Algorand's spec - */ -export class FalconSignatureStruct implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { key: 'sig', valueSchema: new ByteArraySchema() }, // signature - { key: 'idx', valueSchema: new Uint64Schema() }, // index - { key: 'prf', valueSchema: MerkleArrayProof.encodingSchema }, // proof - { key: 'vkey', valueSchema: FalconVerifier.encodingSchema }, // verifyingKey - ]) - ); - - public signature: Uint8Array; - public vectorCommitmentIndex: bigint; - public proof: MerkleArrayProof; - public verifyingKey: FalconVerifier; - - public constructor(params: { - signature: Uint8Array; - index: bigint; - proof: MerkleArrayProof; - verifyingKey: FalconVerifier; - }) { - this.signature = params.signature; - this.vectorCommitmentIndex = params.index; - this.proof = params.proof; - this.verifyingKey = params.verifyingKey; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return FalconSignatureStruct.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['sig', this.signature], - ['idx', this.vectorCommitmentIndex], - ['prf', this.proof.toEncodingData()], - ['vkey', this.verifyingKey.toEncodingData()], - ]); - } - - public static fromEncodingData(data: unknown): FalconSignatureStruct { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded FalconSignatureStruct: ${data}`); - } - return new FalconSignatureStruct({ - signature: data.get('sig'), - index: data.get('idx'), - proof: MerkleArrayProof.fromEncodingData(data.get('prf')), - verifyingKey: FalconVerifier.fromEncodingData(data.get('vkey')), - }); - } -} - -/** - * A SigslotCommit is a single slot in the sigs array that forms the state proof. - */ -export class SigslotCommit implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { key: 's', valueSchema: FalconSignatureStruct.encodingSchema }, // sigslot - { key: 'l', valueSchema: new Uint64Schema() }, // l - ]) - ); - - /** - * Sig is a signature by the participant on the expected message. - */ - public sig: FalconSignatureStruct; - - /** - * L is the total weight of signatures in lower-numbered slots. This is initialized once the builder - * has collected a sufficient number of signatures. - */ - public l: bigint; - - public constructor(params: { sig: FalconSignatureStruct; l: bigint }) { - this.sig = params.sig; - this.l = params.l; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return SigslotCommit.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['s', this.sig.toEncodingData()], - ['l', this.l], - ]); - } - - public static fromEncodingData(data: unknown): SigslotCommit { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded SigslotCommit: ${data}`); - } - return new SigslotCommit({ - sig: FalconSignatureStruct.fromEncodingData(data.get('s')), - l: data.get('l'), - }); - } -} - -/** - * Reveal is a single array position revealed as part of a state proof. It reveals an element of the - * signature array and the corresponding element of the participants array. - */ -export class Reveal implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { key: 's', valueSchema: SigslotCommit.encodingSchema }, // sigslotCommit - { key: 'p', valueSchema: Participant.encodingSchema }, // participant - ]) - ); - - public sigslot: SigslotCommit; - - public participant: Participant; - - public constructor(params: { - sigslot: SigslotCommit; - participant: Participant; - }) { - this.sigslot = params.sigslot; - this.participant = params.participant; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return Reveal.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['s', this.sigslot.toEncodingData()], - ['p', this.participant.toEncodingData()], - ]); - } - - public static fromEncodingData(data: unknown): Reveal { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded Reveal: ${data}`); - } - return new Reveal({ - sigslot: SigslotCommit.fromEncodingData(data.get('s')), - participant: Participant.fromEncodingData(data.get('p')), - }); - } -} - -export class StateProof implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { - key: 'c', // sigCommit - valueSchema: new ByteArraySchema(), - }, - { - key: 'w', // signedWeight - valueSchema: new Uint64Schema(), - }, - { - key: 'S', // sigProofs - valueSchema: MerkleArrayProof.encodingSchema, - }, - { - key: 'P', // partProofs - valueSchema: MerkleArrayProof.encodingSchema, - }, - { - key: 'v', // merkleSignatureSaltVersion - valueSchema: new Uint64Schema(), - }, - { - key: 'r', // reveals - valueSchema: new Uint64MapSchema(Reveal.encodingSchema), - }, - { - key: 'pr', // positionsToReveal - valueSchema: new ArraySchema(new Uint64Schema()), - }, - ]) - ); - - public sigCommit: Uint8Array; - - public signedWeight: bigint; - - public sigProofs: MerkleArrayProof; - - public partProofs: MerkleArrayProof; - - public merkleSignatureSaltVersion: number; - - /** - * Reveals is a sparse map from the position being revealed to the corresponding elements from the - * sigs and participants arrays. - */ - public reveals: Map; - - public positionsToReveal: bigint[]; - - public constructor(params: { - sigCommit: Uint8Array; - signedWeight: bigint; - sigProofs: MerkleArrayProof; - partProofs: MerkleArrayProof; - merkleSignatureSaltVersion: number; - reveals: Map; - positionsToReveal: bigint[]; - }) { - this.sigCommit = params.sigCommit; - this.signedWeight = params.signedWeight; - this.sigProofs = params.sigProofs; - this.partProofs = params.partProofs; - this.merkleSignatureSaltVersion = params.merkleSignatureSaltVersion; - this.reveals = params.reveals; - this.positionsToReveal = params.positionsToReveal; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return StateProof.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['c', this.sigCommit], - ['w', this.signedWeight], - ['S', this.sigProofs.toEncodingData()], - ['P', this.partProofs.toEncodingData()], - ['v', this.merkleSignatureSaltVersion], - [ - 'r', - convertMap(this.reveals, (key, value) => [key, value.toEncodingData()]), - ], - ['pr', this.positionsToReveal], - ]); - } - - public static fromEncodingData(data: unknown): StateProof { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded StateProof: ${data}`); - } - return new StateProof({ - sigCommit: data.get('c'), - signedWeight: data.get('w'), - sigProofs: MerkleArrayProof.fromEncodingData(data.get('S')), - partProofs: MerkleArrayProof.fromEncodingData(data.get('P')), - merkleSignatureSaltVersion: Number(data.get('v')), - reveals: convertMap(data.get('r'), (key, value) => [ - key as bigint, - Reveal.fromEncodingData(value), - ]), - positionsToReveal: data.get('pr'), - }); - } -} - -export class StateProofMessage implements Encodable { - public static readonly encodingSchema = new NamedMapSchema( - allOmitEmpty([ - { key: 'b', valueSchema: new ByteArraySchema() }, // blockHeadersCommitment - { key: 'v', valueSchema: new ByteArraySchema() }, // votersCommitment - { key: 'P', valueSchema: new Uint64Schema() }, // lnProvenWeight - { key: 'f', valueSchema: new Uint64Schema() }, // firstAttestedRound - { key: 'l', valueSchema: new Uint64Schema() }, // lastAttestedRound - ]) - ); - - public blockHeadersCommitment: Uint8Array; - - public votersCommitment: Uint8Array; - - public lnProvenWeight: bigint; - - public firstAttestedRound: bigint; - - public lastAttestedRound: bigint; - - public constructor(params: { - blockHeadersCommitment: Uint8Array; - votersCommitment: Uint8Array; - lnProvenWeight: bigint; - firstAttestedRound: bigint; - lastAttestedRound: bigint; - }) { - this.blockHeadersCommitment = params.blockHeadersCommitment; - this.votersCommitment = params.votersCommitment; - this.lnProvenWeight = params.lnProvenWeight; - this.firstAttestedRound = params.firstAttestedRound; - this.lastAttestedRound = params.lastAttestedRound; - } - - // eslint-disable-next-line class-methods-use-this - public getEncodingSchema(): Schema { - return StateProofMessage.encodingSchema; - } - - public toEncodingData(): Map { - return new Map([ - ['b', this.blockHeadersCommitment], - ['v', this.votersCommitment], - ['P', this.lnProvenWeight], - ['f', this.firstAttestedRound], - ['l', this.lastAttestedRound], - ]); - } - - public static fromEncodingData(data: unknown): StateProofMessage { - if (!(data instanceof Map)) { - throw new Error(`Invalid decoded StateProofMessage: ${data}`); - } - return new StateProofMessage({ - blockHeadersCommitment: data.get('b'), - votersCommitment: data.get('v'), - lnProvenWeight: data.get('P'), - firstAttestedRound: data.get('f'), - lastAttestedRound: data.get('l'), - }); - } - - public static fromMap(data: Map): StateProofMessage { - return new StateProofMessage({ - blockHeadersCommitment: data.get('b') as Uint8Array, - votersCommitment: data.get('v') as Uint8Array, - lnProvenWeight: data.get('P') as bigint, - firstAttestedRound: data.get('f') as bigint, - lastAttestedRound: data.get('l') as bigint, - }); - } -} diff --git a/packages/sdk/src/types/transactions/base.ts b/packages/sdk/src/types/transactions/base.ts deleted file mode 100644 index 2eb4254e..00000000 --- a/packages/sdk/src/types/transactions/base.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { SuggestedParams } from '@algorandfoundation/algokit-algod-client' -import { - AccessReference, - BoxReference, - HoldingReference, - LocalsReference, - OnApplicationComplete, - TransactionType, -} from '@algorandfoundation/algokit-transact' -import { Address } from '../../encoding/address.js' -import { HeartbeatProof } from '../../heartbeat.js' -import { StateProof, StateProofMessage } from '../../stateproof.js' - -/** - * Parameters for resource references in application transactions - */ -export interface ApplicationCallReferenceParams { - /** - * A grouping of the asset index and address of the account - */ - holdings?: HoldingReference[] - - /** A grouping of the application index and address of the account - */ - locals?: LocalsReference[] - - /** - * If true, use the foreign accounts, apps, assets, boxes, holdings, and locals fields to construct the access list - */ - convertToAccess?: boolean -} - -/** - * Contains payment transaction parameters. - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#payment-transaction - */ -export interface PaymentTransactionParams { - /** - * Algorand address of recipient - */ - receiver: string | Address - - /** - * Integer amount to send, in microAlgos. Must be nonnegative. - */ - amount: number | bigint - - /** - * Optional, indicates the sender will close their account and the remaining balance will transfer - * to this account - */ - closeRemainderTo?: string | Address -} - -/** - * Contains key registration transaction parameters - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#key-registration-transaction - */ -export interface KeyRegistrationTransactionParams { - /** - * 32-byte voting key. For key deregistration, leave undefined - */ - voteKey?: Uint8Array - - /** - * 32-byte selection key. For key deregistration, leave undefined - */ - selectionKey?: Uint8Array - - /** - * 64-byte state proof key. For key deregistration, leave undefined - */ - stateProofKey?: Uint8Array - - /** - * First round on which voting keys are valid - */ - voteFirst?: number | bigint - - /** - * Last round on which voting keys are valid - */ - voteLast?: number | bigint - - /** - * The dilution fo the 2-level participation key - */ - voteKeyDilution?: number | bigint - - /** - * Set this value to true to mark this account as nonparticipating. - * - * All new Algorand accounts are participating by default. This means they earn rewards. - */ - nonParticipation?: boolean -} - -/** - * Contains asset configuration transaction parameters. - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#asset-configuration-transaction - */ -export interface AssetConfigurationTransactionParams { - /** - * Asset index uniquely specifying the asset - */ - assetIndex?: number | bigint - - /** - * Total supply of the asset - */ - total?: number | bigint - - /** - * Integer number of decimals for asset unit calcuation - */ - decimals?: number | bigint - - /** - * Whether asset accounts should default to being frozen - */ - defaultFrozen?: boolean - - /** - * The Algorand address in charge of reserve, freeze, clawback, destruction, etc. - */ - manager?: string | Address - - /** - * The Algorand address representing asset reserve - */ - reserve?: string | Address - - /** - * The Algorand address with power to freeze/unfreeze asset holdings - */ - freeze?: string | Address - - /** - * The Algorand address with power to revoke asset holdings - */ - clawback?: string | Address - - /** - * Unit name for this asset - */ - unitName?: string - - /** - * Name for this asset - */ - assetName?: string - - /** - * URL relating to this asset - */ - assetURL?: string - - /** - * Uint8Array containing a hash commitment with respect to the asset. Must be exactly 32 bytes long. - */ - assetMetadataHash?: Uint8Array -} - -/** - * Contains asset transfer transaction parameters. - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#asset-transfer-transaction - */ -export interface AssetTransferTransactionParams { - /** - * Asset index uniquely specifying the asset - */ - assetIndex: number | bigint - - /** - * String representation of Algorand address – if provided, and if "sender" is - * the asset's revocation manager, then deduct from "assetSender" rather than "sender" - */ - assetSender?: string | Address - - /** - * The Algorand address of recipient - */ - receiver: string | Address - - /** - * Integer amount to send - */ - amount: number | bigint - - /** - * Close out remaining asset balance of the sender to this account - */ - closeRemainderTo?: string | Address -} - -/** - * Contains asset freeze transaction parameters. - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#asset-freeze-transaction - */ -export interface AssetFreezeTransactionParams { - /** - * Asset index uniquely specifying the asset - */ - assetIndex: number | bigint - - /** - * Algorand address being frozen or unfrozen - */ - freezeTarget: string | Address - - /** - * true if freezeTarget should be frozen, false if freezeTarget should be allowed to transact - */ - frozen: boolean -} - -/** - * Contains application call transaction parameters. - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#application-call-transaction - */ -export interface ApplicationCallTransactionParams { - /** - * A unique application ID - */ - appIndex: number | bigint - - /** - * What application should do once the program has been run - */ - onComplete: OnApplicationComplete - - /** - * Restricts number of ints in per-user local state - */ - numLocalInts?: number | bigint - - /** - * Restricts number of byte slices in per-user local state - */ - numLocalByteSlices?: number | bigint - - /** - * Restricts number of ints in global state - */ - numGlobalInts?: number | bigint - - /** - * Restricts number of byte slices in global state - */ - numGlobalByteSlices?: number | bigint - - /** - * The compiled TEAL that approves a transaction - */ - approvalProgram?: Uint8Array - - /** - * The compiled TEAL program that runs when clearing state - */ - clearProgram?: Uint8Array - - /** - * Array of Uint8Array, any additional arguments to the application - */ - appArgs?: Uint8Array[] - - /** - * Array of Address strings, any additional accounts to supply to the application - */ - accounts?: Array - - /** - * Array of int, any other apps used by the application, identified by index - */ - foreignApps?: Array - - /** - * Array of int, any assets used by the application, identified by index - */ - foreignAssets?: Array - - /** - * Int representing extra pages of memory to rent during an application create transaction. - */ - extraPages?: number | bigint - - /** - * A grouping of the app ID and name of the box in an Uint8Array - */ - boxes?: BoxReference[] - - /** - * Resources accessed by the application - */ - access?: AccessReference[] - - /** - * The lowest application version for which this transaction should immediately fail. - * 0 indicates that no version check should be performed. - */ - rejectVersion?: number | bigint -} - -/** - * Contains state proof transaction parameters. - */ -export interface StateProofTransactionParams { - /* - * Uint64 identifying a particular configuration of state proofs. - */ - stateProofType?: number | bigint - - /** - * The state proof. - */ - stateProof?: StateProof - - /** - * The state proof message. - */ - message?: StateProofMessage -} - -/** - * Contains heartbeat transaction parameters. - */ -export interface HeartbeatTransactionParams { - /* - * Account address this txn is proving onlineness for - */ - address: Address - - /** - * Signature using HeartbeatAddress's partkey, thereby showing it is online. - */ - proof: HeartbeatProof - - /** - * The block seed for the this transaction's firstValid block. - */ - seed: Uint8Array - - /** - * Must match the hbAddress account's current VoteID - */ - voteID: Uint8Array - - /** - * Must match hbAddress account's current KeyDilution. - */ - keyDilution: bigint -} - -/** - * A full list of all available transaction parameters - * - * The full documentation is available at: - * https://developer.algorand.org/docs/get-details/transactions/transactions/#common-fields-header-and-type - */ -export interface TransactionParams { - /** - * Transaction type - */ - type: TransactionType - - /** - * Algorand address of sender - */ - sender: string | Address - - /** - * Optional, arbitrary data to be included in the transaction's note field - */ - note?: Uint8Array - - /** - * Optional, 32-byte lease to associate with this transaction. - * - * The sender cannot send another transaction with the same lease until the last round of original - * transaction has passed. - */ - lease?: Uint8Array - - /** - * The Algorand address that will be used to authorize all future transactions from the sender, if provided. - */ - rekeyTo?: string | Address - - /** - * Suggested parameters relevant to the network that will accept this transaction - */ - suggestedParams: SuggestedParams - - /** - * Payment transaction parameters. Only set if type is TransactionType.pay - */ - paymentParams?: PaymentTransactionParams - - /** - * Key registration transaction parameters. Only set if type is TransactionType.keyreg - */ - keyregParams?: KeyRegistrationTransactionParams - - /** - * Asset configuration transaction parameters. Only set if type is TransactionType.acfg - */ - assetConfigParams?: AssetConfigurationTransactionParams - - /** - * Asset transfer transaction parameters. Only set if type is TransactionType.axfer - */ - assetTransferParams?: AssetTransferTransactionParams - - /** - * Asset freeze transaction parameters. Only set if type is TransactionType.afrz - */ - assetFreezeParams?: AssetFreezeTransactionParams - - /** - * Application call transaction parameters. Only set if type is TransactionType.appl - */ - appCallParams?: ApplicationCallTransactionParams - - /** - * State proof transaction parameters. Only set if type is TransactionType.stpf - */ - stateProofParams?: StateProofTransactionParams - - /** - * Heartbeat transaction parameters. Only set if type is TransactionType.hb - */ - heartbeatParams?: HeartbeatTransactionParams -} diff --git a/packages/sdk/src/types/transactions/index.ts b/packages/sdk/src/types/transactions/index.ts index de3e406c..828115b0 100644 --- a/packages/sdk/src/types/transactions/index.ts +++ b/packages/sdk/src/types/transactions/index.ts @@ -1,2 +1 @@ -export * from './base.js'; -export * from './encoded.js'; +export * from './encoded.js' diff --git a/packages/sdk/src/wait.ts b/packages/sdk/src/wait.ts deleted file mode 100644 index e6a538e2..00000000 --- a/packages/sdk/src/wait.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AlgodClient, PendingTransactionResponse } from '@algorandfoundation/algokit-algod-client' - -/** - * Wait until a transaction has been confirmed or rejected by the network, or - * until 'waitRounds' number of rounds have passed. - * @param client - An Algodv2 client - * @param txid - The ID of the transaction to wait for. - * @param waitRounds - The maximum number of rounds to wait for. - * @returns A promise that, upon success, will resolve to the output of the - * `pendingTransactionInformation` call for the confirmed transaction. - */ -export async function waitForConfirmation(client: AlgodClient, txid: string, waitRounds: number): Promise { - // Wait until the transaction is confirmed or rejected, or until 'waitRounds' - // number of rounds have passed. - - const status = await client.getStatus() - if (typeof status === 'undefined') { - throw new Error('Unable to get node status') - } - const startRound = status.lastRound + BigInt(1) - const stopRound = startRound + BigInt(waitRounds) - let currentRound = startRound - - /* eslint-disable no-await-in-loop */ - while (currentRound < stopRound) { - let poolError = false - try { - const pendingInfo = await client.pendingTransactionInformation(txid) - - if (pendingInfo.confirmedRound) { - // Got the completed Transaction - return pendingInfo - } - - if (pendingInfo.poolError) { - // If there was a pool error, then the transaction has been rejected - poolError = true - throw new Error(`Transaction Rejected: ${pendingInfo.poolError}`) - } - } catch (err) { - // Ignore errors from PendingTransactionInformation, since it may return 404 if the algod - // instance is behind a load balancer and the request goes to a different algod than the - // one we submitted the transaction to - if (poolError) { - // Rethrow error only if it's because the transaction was rejected - throw err - } - } - - await client.waitForBlock(currentRound) - currentRound += BigInt(1) - } - /* eslint-enable no-await-in-loop */ - throw new Error(`Transaction not confirmed after ${waitRounds} rounds`) -} diff --git a/packages/transact/src/encoding/transaction-dto.ts b/packages/transact/src/encoding/transaction-dto.ts index b0dcf7e3..084417eb 100644 --- a/packages/transact/src/encoding/transaction-dto.ts +++ b/packages/transact/src/encoding/transaction-dto.ts @@ -122,6 +122,9 @@ export type TransactionDto = { /** Extra program pages */ apep?: number + /** Reject version */ + aprv?: number + // Key registration fields (type: 'keyreg') /** Vote key */ votekey?: Uint8Array diff --git a/packages/transact/src/index.ts b/packages/transact/src/index.ts index b7bb5f29..cffc9a96 100644 --- a/packages/transact/src/index.ts +++ b/packages/transact/src/index.ts @@ -1,4 +1,5 @@ export { + TransactionType, assignFee, calculateFee, decodeTransaction, @@ -11,7 +12,6 @@ export { getTransactionId, getTransactionIdRaw, groupTransactions, - TransactionType, type Transaction, } from './transactions/transaction' diff --git a/packages/transact/src/transactions/app-call.ts b/packages/transact/src/transactions/app-call.ts index aadd730b..30fc09f1 100644 --- a/packages/transact/src/transactions/app-call.ts +++ b/packages/transact/src/transactions/app-call.ts @@ -111,6 +111,11 @@ export type AppCallTransactionFields = { * Resources accessed by the application */ accessReferences?: AccessReference[] + + /** + * The lowest application version for which this transaction should immediately fail. 0 indicates that no version check should be performed. + */ + rejectVersion?: number } /** diff --git a/packages/transact/src/transactions/transaction.ts b/packages/transact/src/transactions/transaction.ts index df08ccea..620c32a3 100644 --- a/packages/transact/src/transactions/transaction.ts +++ b/packages/transact/src/transactions/transaction.ts @@ -744,6 +744,8 @@ export function toTransactionDto(transaction: Transaction): TransactionDto { txDto.al = accessList } + + txDto.aprv = numberCodec.encode(transaction.appCall.rejectVersion) txDto.apep = numberCodec.encode(transaction.appCall.extraProgramPages) } @@ -994,6 +996,7 @@ export function fromTransactionDto(transactionDto: TransactionDto): Transaction return result })() : undefined, + rejectVersion: numberCodec.decodeOptional(transactionDto.aprv), extraProgramPages: numberCodec.decodeOptional(transactionDto.apep), ...(transactionDto.apgs !== undefined ? { diff --git a/packages/transact/tests/transaction_asserts.ts b/packages/transact/tests/transaction_asserts.ts index 24ebfa6c..abc012e0 100644 --- a/packages/transact/tests/transaction_asserts.ts +++ b/packages/transact/tests/transaction_asserts.ts @@ -1,4 +1,4 @@ -import * as ed from '@noble/ed25519' // TODO: PD: look into @noble/ed25519 +import * as ed from '@noble/ed25519' import { expect } from 'vitest' import { SignedTransaction, diff --git a/src/__snapshots__/app-deploy.spec.ts.snap b/src/__snapshots__/app-deploy.spec.ts.snap index 2b2e9f2a..d9518157 100644 --- a/src/__snapshots__/app-deploy.spec.ts.snap +++ b/src/__snapshots__/app-deploy.spec.ts.snap @@ -25,7 +25,7 @@ INFO: Detected a TEAL update in app APP_1 for creator ACCOUNT_1 WARN: App is not deletable and onUpdate=ReplaceApp, will attempt to create new app and delete old app, delete will most likely fail INFO: Deploying a new test app for ACCOUNT_1; deploying app with version 2.0. WARN: Deleting existing test app with id APP_1 from ACCOUNT_1 account. -ERROR: Received error executing Atomic Transaction Composer, for more information enable the debug flag | [{"cause":{},"name":"Error"}]" +ERROR: Received error executing Transaction Composer, for more information enable the debug flag | [{"name":"BuildComposerTransactionsError"}]" `; exports[`deploy-app > Deploy failure for replacement of schema broken app fails if onSchemaBreak = Fail 1`] = ` @@ -74,7 +74,7 @@ WARN: Detected a breaking app schema change in app APP_1: | [{"from":{"globalInt INFO: App is not deletable but onSchemaBreak=ReplaceApp, will attempt to delete app, delete will most likely fail INFO: Deploying a new test app for ACCOUNT_1; deploying app with version 2.0. WARN: Deleting existing test app with id APP_1 from ACCOUNT_1 account. -ERROR: Received error executing Atomic Transaction Composer, for more information enable the debug flag | [{"cause":{},"name":"Error"}]" +ERROR: Received error executing Transaction Composer, for more information enable the debug flag | [{"name":"BuildComposerTransactionsError"}]" `; exports[`deploy-app > Deploy update to immutable updated app fails 1`] = ` @@ -83,7 +83,7 @@ INFO: Existing app test found by creator ACCOUNT_1, with app id APP_1 and versio INFO: Detected a TEAL update in app APP_1 for creator ACCOUNT_1 WARN: App is not updatable but onUpdate=UpdateApp, will attempt to update app, update will most likely fail INFO: Updating existing test app for ACCOUNT_1 to version 2.0. -ERROR: Received error executing Atomic Transaction Composer and debug flag enabled; attempting simulation to get more information | [{"cause":{},"name":"Error","traces":[]}]" +ERROR: Received error executing Transaction Composer and debug flag enabled; attempting simulation to get more information | [{"name":"BuildComposerTransactionsError","traces":[]}]" `; exports[`deploy-app > Deploy update to updatable updated app 1`] = ` diff --git a/src/app.ts b/src/app.ts index 6abe1b26..290c57d9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -374,10 +374,10 @@ export function getAppArgsForTransaction(args?: RawAppCallArgs) { /** * @deprecated Use `TransactionComposer` methods to construct transactions instead. * - * Returns the app args ready to load onto an ABI method call in `AtomicTransactionComposer` + * Returns the app args ready to load onto an ABI method call in `TransactionComposer` * @param args The ABI app call args * @param from The transaction signer - * @returns The parameters ready to pass into `addMethodCall` within AtomicTransactionComposer + * @returns The parameters ready to pass into `addMethodCall` within TransactionComposer */ export async function getAppArgsForABICall(args: ABIAppCallArgs, from: SendTransactionFrom) { return _getAppArgsForABICall(args, from) diff --git a/src/transaction/index.ts b/src/transaction/index.ts index d7a2bfc1..22cdf8b1 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -1,2 +1,2 @@ -export * from './perform-atomic-transaction-composer-simulate' +export * from './perform-transaction-composer-simulate' export * from './transaction' diff --git a/src/transaction/legacy-bridge.ts b/src/transaction/legacy-bridge.ts index 2b898301..bfa5cb38 100644 --- a/src/transaction/legacy-bridge.ts +++ b/src/transaction/legacy-bridge.ts @@ -62,21 +62,21 @@ export async function legacySendTransactionBridge ({ txn, signer: 'signers' in transaction ? (transaction.signers.get(i) ?? getSenderTransactionSigner(from)) : getSenderTransactionSigner(from), })) - .forEach((t) => sendParams.atc!.addTransaction(t)) - // Populate ATC with method calls + .forEach((t) => sendParams.transactionComposer!.addTransaction(t.txn, t.signer)) + // Populate the composer with method calls if ('transactions' in transaction) { - transaction.methodCalls.forEach((m, i) => sendParams.atc!['methodCalls'].set(i + baseIndex, m)) + transaction.methodCalls.forEach((m, i) => sendParams.transactionComposer!['methodCalls'].set(i + baseIndex, m)) } } return { transaction: new TransactionWrapper(txns.at(-1)!), transactions: txns.map((t) => new TransactionWrapper(t)) } diff --git a/src/transaction/perform-atomic-transaction-composer-simulate.ts b/src/transaction/perform-atomic-transaction-composer-simulate.ts deleted file mode 100644 index 27d9651c..00000000 --- a/src/transaction/perform-atomic-transaction-composer-simulate.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - AlgodClient, - SimulateRequest, - SimulateRequestTransactionGroup, - SimulateTraceConfig, -} from '@algorandfoundation/algokit-algod-client' -import { EMPTY_SIGNATURE } from '@algorandfoundation/algokit-common' -import { AtomicTransactionComposer } from '@algorandfoundation/sdk' - -/** - * Performs a simulation of the transactions loaded into the given AtomicTransactionComposer. - * Uses empty transaction signers for all transactions. - * - * @param atc The AtomicTransactionComposer with transaction(s) loaded. - * @param algod An Algod client to perform the simulation. - * @returns The simulation result, which includes various details about how the transactions would be processed. - */ -export async function performAtomicTransactionComposerSimulate( - atc: AtomicTransactionComposer, - algod: AlgodClient, - options?: Omit, -) { - const transactionsWithSigners = atc.buildGroup() - - const simulateRequest = { - ...(options ?? { - allowEmptySignatures: true, - fixSigners: true, - allowMoreLogging: true, - execTraceConfig: { - enable: true, - scratchChange: true, - stackChange: true, - stateChange: true, - } satisfies SimulateTraceConfig, - }), - txnGroups: [ - { - txns: transactionsWithSigners.map((txn) => ({ - txn: txn.txn, - signature: EMPTY_SIGNATURE, - })), - } satisfies SimulateRequestTransactionGroup, - ], - } satisfies SimulateRequest - const simulateResult = await algod.simulateTransaction(simulateRequest) - return simulateResult -} diff --git a/src/transaction/perform-transaction-composer-simulate.ts b/src/transaction/perform-transaction-composer-simulate.ts new file mode 100644 index 00000000..92978cb7 --- /dev/null +++ b/src/transaction/perform-transaction-composer-simulate.ts @@ -0,0 +1,33 @@ +import { RawSimulateOptions, SimulateOptions, TransactionComposer } from '../types/composer' + +/** + * @deprecated Use `composer.simulate` with + * - `allowEmptySignatures` flag set to true + * - `throwOnFailure` flag set to false + * + * Performs a simulation of the transactions loaded into the given TransactionComposer. + * Uses empty transaction signers for all transactions. + * + * @param composer The TransactionComposer with transaction(s) loaded. + * @returns The simulation result, which includes various details about how the transactions would be processed. + */ +export async function performTransactionComposerSimulate(composer: TransactionComposer, options?: RawSimulateOptions) { + const simulateOptions = { + ...(options ?? { + skipSignatures: true, + allowEmptySignatures: true, + fixSigners: true, + allowMoreLogging: true, + execTraceConfig: { + enable: true, + scratchChange: true, + stackChange: true, + stateChange: true, + }, + throwOnFailure: false, + }), + } satisfies SimulateOptions + + const simulateResult = await composer.simulate(simulateOptions) + return simulateResult.simulateResponse +} diff --git a/src/transaction/transaction.spec.ts b/src/transaction/transaction.spec.ts index ff9fc488..ada01a23 100644 --- a/src/transaction/transaction.spec.ts +++ b/src/transaction/transaction.spec.ts @@ -11,11 +11,12 @@ import v9ARC32 from '../../tests/example-contracts/resource-packer/artifacts/Res import { algo, microAlgo } from '../amount' import { Config } from '../config' import { algorandFixture } from '../testing' +import { TransactionSignerAccount } from '../types/account' import { AlgoAmount } from '../types/amount' import { AppClient } from '../types/app-client' import { PaymentParams, TransactionComposer } from '../types/composer' import { Arc2TransactionNote } from '../types/transaction' -import { getABIReturnValue, populateAppCallResources, waitForConfirmation } from './transaction' +import { getABIReturnValue, waitForConfirmation } from './transaction' describe('transaction', () => { const localnet = algorandFixture() @@ -1044,7 +1045,7 @@ describe('Resource population: meta', () => { let externalClient: AppClient - let testAccount: algosdk.Address & algosdk.Account + let testAccount: algosdk.Address & algosdk.Account & TransactionSignerAccount beforeEach(fixture.newScope) @@ -1132,7 +1133,14 @@ describe('Resource population: meta', () => { const result = await externalClient.send.call({ method: 'createBoxInNewApp', - args: [algorand.createTransaction.payment({ sender: testAccount, receiver: externalClient.appAddress, amount: (1).algo() })], + args: [ + algorand.createTransaction.payment({ + sender: testAccount, + receiver: externalClient.appAddress, + amount: (1).algo(), + signer: testAccount, + }), + ], staticFee: (4_000).microAlgo(), }) @@ -1185,12 +1193,10 @@ describe('Resource population: meta', () => { composer.addAppCallMethodCall(await v9AppClient.params.call({ method: 'dummy', note: `${i}` })) } - const atc = (await composer.build()).atc const getResources = async () => { - const populatedAtc = await populateAppCallResources(atc, algorand.client.algod) - const resources = [] - for (const txnWithSigner of populatedAtc.buildGroup()) { + const transactionsWithSigners = (await composer.build()).transactions + for (const txnWithSigner of transactionsWithSigners) { const txn = txnWithSigner.txn for (const acct of txn.appCall?.accountReferences ?? []) { diff --git a/src/transaction/transaction.ts b/src/transaction/transaction.ts index c824c628..8fac1943 100644 --- a/src/transaction/transaction.ts +++ b/src/transaction/transaction.ts @@ -1,31 +1,19 @@ -import { - AlgodClient, - AlgorandSerializer, - ApplicationLocalReference, - AssetHoldingReference, - BoxReference, - PendingTransactionResponse, - SimulateRequest, - SimulationTransactionExecTraceMeta, - SuggestedParams, -} from '@algorandfoundation/algokit-algod-client' -import type { AppCallTransactionFields } from '@algorandfoundation/algokit-transact' -import { Transaction, TransactionType, encodeTransaction, getTransactionId } from '@algorandfoundation/algokit-transact' +import { AlgodClient, PendingTransactionResponse, SuggestedParams } from '@algorandfoundation/algokit-algod-client' +import { Transaction, getTransactionId } from '@algorandfoundation/algokit-transact' import * as algosdk from '@algorandfoundation/sdk' -import { ABIMethod, ABIReturnType, Address, AtomicTransactionComposer, TransactionSigner, stringifyJSON } from '@algorandfoundation/sdk' -import { Buffer } from 'buffer' +import { ABIReturnType, TransactionSigner } from '@algorandfoundation/sdk' import { Config } from '../config' import { AlgoAmount } from '../types/amount' import { ABIReturn } from '../types/app' -import { EventType } from '../types/lifecycle-events' +import { TransactionComposer } from '../types/composer' import { - AdditionalAtomicTransactionComposerContext, - AtomicTransactionComposerToSend, - SendAtomicTransactionComposerResults, + AdditionalTransactionComposerContext, SendParams, + SendTransactionComposerResults, SendTransactionFrom, SendTransactionParams, SendTransactionResult, + TransactionComposerToSend, TransactionGroupToSend, TransactionNote, TransactionToSign, @@ -33,13 +21,12 @@ import { wrapPendingTransactionResponse, } from '../types/transaction' import { asJson, convertABIDecodedBigIntToNumber, convertAbiByteArrays, toNumber } from '../util' -import { performAtomicTransactionComposerSimulate } from './perform-atomic-transaction-composer-simulate' - -// Type aliases for compatibility -type ApplicationTransactionFields = AppCallTransactionFields +/** Represents an unsigned transactions and a signer that can authorize that transaction. */ export interface TransactionWithSigner { + /** An unsigned transaction */ txn: Transaction + /** A transaction signer that can authorize txn */ signer: TransactionSigner } @@ -131,7 +118,7 @@ export const getSenderAddress = function (sender: string | SendTransactionFrom): * construct an `algosdk.TransactionWithSigner` manually instead. * * Given a transaction in a variety of supported formats, returns a TransactionWithSigner object ready to be passed to an - * AtomicTransactionComposer's addTransaction method. + * TransactionComposer's addTransaction method. * @param transaction One of: A TransactionWithSigner object (returned as is), a TransactionToSign object (signer is obtained from the * signer property), a Transaction object (signer is extracted from the defaultSender parameter), an async SendTransactionResult returned by * one of algokit utils' helpers (signer is obtained from the defaultSender parameter) @@ -230,12 +217,12 @@ export const sendTransaction = async function ( algod: AlgodClient, ): Promise { const { transaction, from, sendParams } = send - const { skipSending, skipWaiting, fee, maxFee, suppressLog, maxRoundsToWaitForConfirmation, atc } = sendParams ?? {} + const { skipSending, skipWaiting, fee, maxFee, suppressLog, maxRoundsToWaitForConfirmation, transactionComposer } = sendParams ?? {} controlFees(transaction, { fee, maxFee }) - if (atc) { - atc.addTransaction({ txn: transaction, signer: getSenderTransactionSigner(from) }) + if (transactionComposer) { + transactionComposer.addTransaction(transaction, getSenderTransactionSigner(from)) return { transaction: new TransactionWrapper(transaction) } } @@ -243,173 +230,39 @@ export const sendTransaction = async function ( return { transaction: new TransactionWrapper(transaction) } } - let txnToSend = transaction - - const populateAppCallResources = sendParams?.populateAppCallResources ?? Config.populateAppCallResources - - // Populate resources if the transaction is an appcall and populateAppCallResources wasn't explicitly set to false - if (txnToSend.type === TransactionType.AppCall && populateAppCallResources) { - const newAtc = new AtomicTransactionComposer() - newAtc.addTransaction({ txn: txnToSend, signer: getSenderTransactionSigner(from) }) - const atc = await prepareGroupForSending(newAtc, algod, { ...sendParams, populateAppCallResources }) - txnToSend = atc.buildGroup()[0].txn - } - - const signedTransaction = await signTransaction(txnToSend, from) + const composer = new TransactionComposer({ + composerConfig: { + populateAppCallResources: sendParams?.populateAppCallResources ?? Config.populateAppCallResources, + coverAppCallInnerTransactionFees: false, + }, + algod: algod, + getSigner: (address) => { + throw new Error(`Signer not found for address ${address.toString()}`) + }, + }) + composer.addTransaction(transaction, getSenderTransactionSigner(from)) - await algod.sendRawTransaction(signedTransaction) + const sendResult = await composer.send({ + // if skipWaiting to true, do not wait + // if skipWaiting to set, wait for maxRoundsToWaitForConfirmation or 5 rounds + maxRoundsToWaitForConfirmation: skipWaiting ? 0 : (maxRoundsToWaitForConfirmation ?? 5), + suppressLog: suppressLog, + }) Config.getLogger(suppressLog).verbose( - `Sent transaction ID ${getTransactionId(txnToSend)} ${txnToSend.type} from ${getSenderAddress(from)}`, + `Sent transaction ID ${getTransactionId(transaction)} ${transaction.type} from ${getSenderAddress(from)}`, ) - let confirmation: PendingTransactionResponse | undefined = undefined - if (!skipWaiting) { - confirmation = await waitForConfirmation(getTransactionId(txnToSend), maxRoundsToWaitForConfirmation ?? 5, algod) - } - + const confirmation = sendResult.confirmations.at(-1)! return { - transaction: new TransactionWrapper(txnToSend), + transaction: new TransactionWrapper(transaction), confirmation: confirmation ? wrapPendingTransactionResponse(confirmation) : undefined, } } /** - * Get the execution info of a transaction group for the given ATC - * The function uses the simulate endpoint and depending on the sendParams can return the following: - * - The unnamed resources accessed by the group - * - The unnamed resources accessed by each transaction in the group - * - The required fee delta for each transaction in the group. A positive value indicates a fee deficit, a negative value indicates a surplus. - * - * @param atc The ATC containing the txn group - * @param algod The algod client to use for the simulation - * @param sendParams The send params for the transaction group - * @param additionalAtcContext Additional ATC context used to determine how best to alter transactions in the group - * @returns The execution info for the group - */ -async function getGroupExecutionInfo( - atc: algosdk.AtomicTransactionComposer, - algod: AlgodClient, - sendParams: SendParams, - additionalAtcContext?: AdditionalAtomicTransactionComposerContext, -) { - const simulateRequest: SimulateRequest = { - txnGroups: [], - allowUnnamedResources: true, - allowEmptySignatures: true, - fixSigners: true, - } - - const nullSigner = algosdk.makeEmptyTransactionSigner() - - const emptySignerAtc = atc.clone() - - const appCallIndexesWithoutMaxFees: number[] = [] - emptySignerAtc['transactions'].forEach((t: algosdk.TransactionWithSigner, i: number) => { - t.signer = nullSigner - - if (sendParams.coverAppCallInnerTransactionFees && t.txn.type === TransactionType.AppCall) { - if (!additionalAtcContext?.suggestedParams) { - throw Error(`Please provide additionalAtcContext.suggestedParams when coverAppCallInnerTransactionFees is enabled`) - } - - const maxFee = additionalAtcContext?.maxFees?.get(i)?.microAlgo - if (maxFee === undefined) { - appCallIndexesWithoutMaxFees.push(i) - } else { - t.txn.fee = maxFee - } - } - }) - - if (sendParams.coverAppCallInnerTransactionFees && appCallIndexesWithoutMaxFees.length > 0) { - throw Error( - `Please provide a maxFee for each app call transaction when coverAppCallInnerTransactionFees is enabled. Required for transaction ${appCallIndexesWithoutMaxFees.join(', ')}`, - ) - } - - const perByteTxnFee = BigInt(additionalAtcContext?.suggestedParams.fee ?? 0n) - const minTxnFee = BigInt(additionalAtcContext?.suggestedParams.minFee ?? 1000n) - - const result = await emptySignerAtc.simulate(algod, simulateRequest) - - const groupResponse = result.simulateResponse.txnGroups[0] - - if (groupResponse.failureMessage) { - if (sendParams.coverAppCallInnerTransactionFees && groupResponse.failureMessage.match(/fee too small/)) { - throw Error(`Fees were too small to resolve execution info via simulate. You may need to increase an app call transaction maxFee.`) - } - - throw Error(`Error resolving execution info via simulate in transaction ${groupResponse.failedAt}: ${groupResponse.failureMessage}`) - } - - const sortedResources = groupResponse.unnamedResourcesAccessed - - // NOTE: We explicitly want to avoid localeCompare as that can lead to different results in different environments - const compare = (a: string | bigint, b: string | bigint) => (a < b ? -1 : a > b ? 1 : 0) - - if (sortedResources) { - sortedResources.accounts?.sort((a, b) => compare(a.toString(), b.toString())) - sortedResources.assets?.sort(compare) - sortedResources.apps?.sort(compare) - sortedResources.boxes?.sort((a, b) => { - const aStr = `${a.app}-${a.name}` - const bStr = `${b.app}-${b.name}` - return compare(aStr, bStr) - }) - sortedResources.appLocals?.sort((a, b) => { - const aStr = `${a.app}-${a.account}` - const bStr = `${b.app}-${b.account}` - return compare(aStr, bStr) - }) - sortedResources.assetHoldings?.sort((a, b) => { - const aStr = `${a.asset}-${a.account}` - const bStr = `${b.asset}-${b.account}` - return compare(aStr, bStr) - }) - } - - return { - groupUnnamedResourcesAccessed: sendParams.populateAppCallResources ? sortedResources : undefined, - txns: groupResponse.txnResults.map((txn, i) => { - const originalTxn = atc['transactions'][i].txn as Transaction - - let requiredFeeDelta = 0n - if (sendParams.coverAppCallInnerTransactionFees) { - // Min fee calc is lifted from algosdk https://github.com/algorand/js-algorand-sdk/blob/6973ff583b243ddb0632e91f4c0383021430a789/src/transaction.ts#L710 - // 75 is the number of bytes added to a txn after signing it - const parentPerByteFee = perByteTxnFee * BigInt(encodeTransaction(originalTxn).length + 75) - const parentMinFee = parentPerByteFee < minTxnFee ? minTxnFee : parentPerByteFee - const parentFeeDelta = parentMinFee - (originalTxn.fee ?? 0n) - if (originalTxn.type === TransactionType.AppCall) { - const calculateInnerFeeDelta = (itxns: PendingTransactionResponse[], acc: bigint = 0n): bigint => { - // Surplus inner transaction fees do not pool up to the parent transaction. - // Additionally surplus inner transaction fees only pool from sibling transactions that are sent prior to a given inner transaction, hence why we iterate in reverse order. - return itxns.reverse().reduce((acc, itxn) => { - const currentFeeDelta = - (itxn.innerTxns && itxn.innerTxns.length > 0 ? calculateInnerFeeDelta(itxn.innerTxns, acc) : acc) + - (minTxnFee - (itxn.txn.txn.fee ?? 0n)) // Inner transactions don't require per byte fees - return currentFeeDelta < 0n ? 0n : currentFeeDelta - }, acc) - } - - const innerFeeDelta = calculateInnerFeeDelta(txn.txnResult.innerTxns ?? []) - requiredFeeDelta = innerFeeDelta + parentFeeDelta - } else { - requiredFeeDelta = parentFeeDelta - } - } - - return { - unnamedResourcesAccessed: sendParams.populateAppCallResources ? txn.unnamedResourcesAccessed : undefined, - requiredFeeDelta, - } - }), - } -} - -/** - * Take an existing Atomic Transaction Composer and return a new one with the required + * @deprecated Use `composer.build()` directly + * Take an existing Transaction Composer and return a new one with the required * app call resources populated into it * * @param algod The algod client to use for the simulation @@ -425,547 +278,60 @@ async function getGroupExecutionInfo( * See https://github.com/algorand/go-algorand/pull/5684 * */ -export async function populateAppCallResources(atc: algosdk.AtomicTransactionComposer, algod: AlgodClient) { - return await prepareGroupForSending(atc, algod, { populateAppCallResources: true }) +export async function populateAppCallResources(composer: TransactionComposer) { + await composer.build() + return composer } /** - * Take an existing Atomic Transaction Composer and return a new one with changes applied to the transactions + * @deprecated Use `composer.setMaxFees()` instead if you need to set max fees for transactions. + * Use `composer.build()` instead if you need to build transactions with resource population. + * + * Take an existing Transaction Composer and return a new one with changes applied to the transactions * based on the supplied sendParams to prepare it for sending. - * Please note, that before calling `.execute()` on the returned ATC, you must call `.buildGroup()`. * - * @param algod The algod client to use for the simulation - * @param atc The ATC containing the txn group + * @param composer The Transaction Composer containing the txn group * @param sendParams The send params for the transaction group - * @param additionalAtcContext Additional ATC context used to determine how best to change the transactions in the group - * @returns A new ATC with the changes applied + * @param additionalAtcContext Additional context used to determine how best to change the transactions in the group + * @returns A new Transaction Composer with the changes applied * * @privateRemarks * Parts of this function will eventually be implemented in algod. Namely: * - Simulate will return information on how to populate reference arrays, see https://github.com/algorand/go-algorand/pull/6015 */ export async function prepareGroupForSending( - atc: algosdk.AtomicTransactionComposer, - algod: AlgodClient, + composer: TransactionComposer, sendParams: SendParams, - additionalAtcContext?: AdditionalAtomicTransactionComposerContext, + additionalAtcContext?: AdditionalTransactionComposerContext, ) { - const executionInfo = await getGroupExecutionInfo(atc, algod, sendParams, additionalAtcContext) - const group = atc.buildGroup() - - const [_, additionalTransactionFees] = sendParams.coverAppCallInnerTransactionFees - ? executionInfo.txns - .map((txn, i) => { - const groupIndex = i - const txnInGroup = group[groupIndex].txn - const maxFee = additionalAtcContext?.maxFees?.get(i)?.microAlgo - const immutableFee = maxFee !== undefined && maxFee === txnInGroup.fee - // Because we don't alter non app call transaction, they take priority - const priorityMultiplier = - txn.requiredFeeDelta > 0n && (immutableFee || txnInGroup.type !== TransactionType.AppCall) ? 1_000n : 1n - - return { - ...txn, - groupIndex, - // Measures the priority level of covering the transaction fee using the surplus group fees. The higher the number, the higher the priority. - surplusFeePriorityLevel: txn.requiredFeeDelta > 0n ? txn.requiredFeeDelta * priorityMultiplier : -1n, - } - }) - .sort((a, b) => { - return a.surplusFeePriorityLevel > b.surplusFeePriorityLevel ? -1 : a.surplusFeePriorityLevel < b.surplusFeePriorityLevel ? 1 : 0 - }) - .reduce( - (acc, { groupIndex, requiredFeeDelta }) => { - if (requiredFeeDelta > 0n) { - // There is a fee deficit on the transaction - let surplusGroupFees = acc[0] - const additionalTransactionFees = acc[1] - const additionalFeeDelta = requiredFeeDelta - surplusGroupFees - if (additionalFeeDelta <= 0n) { - // The surplus group fees fully cover the required fee delta - surplusGroupFees = -additionalFeeDelta - } else { - // The surplus group fees do not fully cover the required fee delta, use what is available - additionalTransactionFees.set(groupIndex, additionalFeeDelta) - surplusGroupFees = 0n - } - return [surplusGroupFees, additionalTransactionFees] as const - } - return acc - }, - [ - executionInfo.txns.reduce((acc, { requiredFeeDelta }) => { - if (requiredFeeDelta < 0n) { - return acc + -requiredFeeDelta - } - return acc - }, 0n), - new Map(), - ] as const, - ) - : [0n, new Map()] - - const appCallHasAccessReferences = (txn: Transaction) => { - return txn.type === TransactionType.AppCall && txn.appCall?.accessReferences && txn.appCall?.accessReferences.length > 0 - } - - const indexesWithAccessReferences: number[] = [] - - executionInfo.txns.forEach(({ unnamedResourcesAccessed: r }, i) => { - // Populate Transaction App Call Resources - if (sendParams.populateAppCallResources && group[i].txn.type === TransactionType.AppCall) { - const hasAccessReferences = appCallHasAccessReferences(group[i].txn) - - if (hasAccessReferences && (r || executionInfo.groupUnnamedResourcesAccessed)) { - indexesWithAccessReferences.push(i) - } - - if (r && !hasAccessReferences) { - if (r.boxes || r.extraBoxRefs) throw Error('Unexpected boxes at the transaction level') - if (r.appLocals) throw Error('Unexpected app local at the transaction level') - if (r.assetHoldings) - throw Error('Unexpected asset holding at the transaction level') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(group[i].txn as any)['appCall'] = { - ...group[i].txn.appCall, - accountReferences: [...(group[i].txn?.appCall?.accountReferences ?? []), ...(r.accounts ?? [])], - appReferences: [...(group[i].txn?.appCall?.appReferences ?? []), ...(r.apps ?? [])], - assetReferences: [...(group[i].txn?.appCall?.assetReferences ?? []), ...(r.assets ?? [])], - boxReferences: [...(group[i].txn?.appCall?.boxReferences ?? [])], - } satisfies Partial - - const accounts = group[i].txn.appCall?.accountReferences?.length ?? 0 - if (accounts > MAX_APP_CALL_ACCOUNT_REFERENCES) - throw Error(`Account reference limit of ${MAX_APP_CALL_ACCOUNT_REFERENCES} exceeded in transaction ${i}`) - const assets = group[i].txn.appCall?.assetReferences?.length ?? 0 - const apps = group[i].txn.appCall?.appReferences?.length ?? 0 - const boxes = group[i].txn.appCall?.boxReferences?.length ?? 0 - if (accounts + assets + apps + boxes > MAX_APP_CALL_FOREIGN_REFERENCES) { - throw Error(`Resource reference limit of ${MAX_APP_CALL_FOREIGN_REFERENCES} exceeded in transaction ${i}`) - } - } - } - - // Cover App Call Inner Transaction Fees - if (sendParams.coverAppCallInnerTransactionFees) { - const additionalTransactionFee = additionalTransactionFees.get(i) - - if (additionalTransactionFee !== undefined) { - if (group[i].txn.type !== TransactionType.AppCall) { - throw Error(`An additional fee of ${additionalTransactionFee} µALGO is required for non app call transaction ${i}`) - } - const transactionFee = (group[i].txn.fee ?? 0n) + additionalTransactionFee - const maxFee = additionalAtcContext?.maxFees?.get(i)?.microAlgo - if (maxFee === undefined || transactionFee > maxFee) { - throw Error( - `Calculated transaction fee ${transactionFee} µALGO is greater than max of ${maxFee ?? 'undefined'} for transaction ${i}`, - ) - } - group[i].txn.fee = transactionFee - } - } + const newComposer = composer.clone({ + coverAppCallInnerTransactionFees: sendParams.coverAppCallInnerTransactionFees ?? false, + populateAppCallResources: sendParams.populateAppCallResources ?? true, }) - // Populate Group App Call Resources - if (sendParams.populateAppCallResources) { - if (indexesWithAccessReferences.length > 0) { - Config.logger.warn( - `Resource population will be skipped for transaction indexes ${indexesWithAccessReferences.join(', ')} as they use access references.`, - ) - } - - const populateGroupResource = ( - txns: algosdk.TransactionWithSigner[], - reference: string | BoxReference | ApplicationLocalReference | AssetHoldingReference | bigint | number | Address, - type: 'account' | 'assetHolding' | 'appLocal' | 'app' | 'box' | 'asset', - ): void => { - const isApplBelowLimit = (t: algosdk.TransactionWithSigner) => { - if (t.txn.type !== TransactionType.AppCall) return false - if (appCallHasAccessReferences(t.txn)) return false - - const accounts = t.txn.appCall?.accountReferences?.length ?? 0 - const assets = t.txn.appCall?.assetReferences?.length ?? 0 - const apps = t.txn.appCall?.appReferences?.length ?? 0 - const boxes = t.txn.appCall?.boxReferences?.length ?? 0 - - return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - } - - // If this is a asset holding or app local, first try to find a transaction that already has the account available - if (type === 'assetHolding' || type === 'appLocal') { - const { account } = reference as ApplicationLocalReference | AssetHoldingReference - - let txnIndex = txns.findIndex((t) => { - if (!isApplBelowLimit(t)) return false - - return ( - // account is in the foreign accounts array - t.txn.appCall?.accountReferences?.map((a) => a.toString()).includes(account.toString()) || - // account is available as an app account - t.txn.appCall?.appReferences?.map((a) => algosdk.getApplicationAddress(a).toString()).includes(account.toString()) || - // account is available since it's in one of the fields - Object.values(t.txn).some((f) => - stringifyJSON(f, (_, v) => (v instanceof Address ? v.toString() : v))?.includes(account.toString()), - ) - ) - }) - - if (txnIndex > -1) { - if (type === 'assetHolding') { - const { asset } = reference as AssetHoldingReference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - assetReferences: [...(txns[txnIndex].txn?.appCall?.assetReferences ?? []), ...[asset]], - } satisfies Partial - } else { - const { app } = reference as ApplicationLocalReference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - appReferences: [...(txns[txnIndex].txn?.appCall?.appReferences ?? []), ...[app]], - } satisfies Partial - } - return - } - - // Now try to find a txn that already has that app or asset available - txnIndex = txns.findIndex((t) => { - if (!isApplBelowLimit(t)) return false - - // check if there is space in the accounts array - if ((t.txn.appCall?.accountReferences?.length ?? 0) >= MAX_APP_CALL_ACCOUNT_REFERENCES) return false - - if (type === 'assetHolding') { - const { asset } = reference as AssetHoldingReference - return t.txn.appCall?.assetReferences?.includes(asset) - } else { - const { app } = reference as ApplicationLocalReference - return t.txn.appCall?.appReferences?.includes(app) || t.txn.appCall?.appId === app - } - }) - - if (txnIndex > -1) { - const { account } = reference as AssetHoldingReference | ApplicationLocalReference - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - accountReferences: [...(txns[txnIndex].txn?.appCall?.accountReferences ?? []), ...[account]], - } satisfies Partial - - return - } - } - - // If this is a box, first try to find a transaction that already has the app available - if (type === 'box') { - const { app, name } = reference as BoxReference - - const txnIndex = txns.findIndex((t) => { - if (!isApplBelowLimit(t)) return false - - // If the app is in the foreign array OR the app being called, then we know it's available - return t.txn.appCall?.appReferences?.includes(app) || t.txn.appCall?.appId === app - }) - - if (txnIndex > -1) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - boxReferences: [...(txns[txnIndex].txn?.appCall?.boxReferences ?? []), ...[{ appId: app, name: name }]], - } satisfies Partial - - return - } - } - - // Find the txn index to put the reference(s) - const txnIndex = txns.findIndex((t) => { - if (t.txn.type !== TransactionType.AppCall) return false - if (appCallHasAccessReferences(t.txn)) return false - - const accounts = t.txn.appCall?.accountReferences?.length ?? 0 - if (type === 'account') return accounts < MAX_APP_CALL_ACCOUNT_REFERENCES - - const assets = t.txn.appCall?.assetReferences?.length ?? 0 - const apps = t.txn.appCall?.appReferences?.length ?? 0 - const boxes = t.txn.appCall?.boxReferences?.length ?? 0 - - // If we're adding local state or asset holding, we need space for the acocunt and the other reference - if (type === 'assetHolding' || type === 'appLocal') { - return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - 1 && accounts < MAX_APP_CALL_ACCOUNT_REFERENCES - } - - // If we're adding a box, we need space for both the box ref and the app ref - if (type === 'box' && BigInt((reference as BoxReference).app) !== BigInt(0)) { - return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - 1 - } - - return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES - }) - - if (txnIndex === -1) { - throw Error('No more transactions below reference limit. Add another app call to the group.') - } - - if (type === 'account') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - accountReferences: [...(txns[txnIndex].txn?.appCall?.accountReferences ?? []), ...[(reference as Address).toString()]], - } satisfies Partial - } else if (type === 'app') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - appReferences: [ - ...(txns[txnIndex].txn?.appCall?.appReferences ?? []), - ...[typeof reference === 'bigint' ? reference : BigInt(reference as number)], - ], - } satisfies Partial - } else if (type === 'box') { - const { app, name } = reference as BoxReference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - boxReferences: [...(txns[txnIndex].txn?.appCall?.boxReferences ?? []), ...[{ appId: app, name }]], - } satisfies Partial - - if (app.toString() !== '0') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - appReferences: [...(txns[txnIndex].txn?.appCall?.appReferences ?? []), ...[app]], - } satisfies Partial - } - } else if (type === 'assetHolding') { - const { asset, account } = reference as AssetHoldingReference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - assetReferences: [...(txns[txnIndex].txn?.appCall?.assetReferences ?? []), ...[asset]], - accountReferences: [...(txns[txnIndex].txn?.appCall?.accountReferences ?? []), ...[account]], - } satisfies Partial - } else if (type === 'appLocal') { - const { app, account } = reference as ApplicationLocalReference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - appReferences: [...(txns[txnIndex].txn?.appCall?.appReferences ?? []), ...[app]], - accountReferences: [...(txns[txnIndex].txn?.appCall?.accountReferences ?? []), ...[account]], - } satisfies Partial - } else if (type === 'asset') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(txns[txnIndex].txn as any)['appCall'] = { - ...txns[txnIndex].txn.appCall, - assetReferences: [ - ...(txns[txnIndex].txn?.appCall?.assetReferences ?? []), - ...[typeof reference === 'bigint' ? reference : BigInt(reference as number)], - ], - } satisfies Partial - } - } - - const g = executionInfo.groupUnnamedResourcesAccessed - - if (g) { - // Do cross-reference resources first because they are the most restrictive in terms - // of which transactions can be used - g.appLocals?.forEach((a) => { - populateGroupResource(group, a, 'appLocal') - - // Remove resources from the group if we're adding them here - g.accounts = g.accounts?.filter((acc) => acc !== a.account) - g.apps = g.apps?.filter((app) => BigInt(app) !== BigInt(a.app)) - }) - - g.assetHoldings?.forEach((a) => { - populateGroupResource(group, a, 'assetHolding') - - // Remove resources from the group if we're adding them here - g.accounts = g.accounts?.filter((acc) => acc !== a.account) - g.assets = g.assets?.filter((asset) => BigInt(asset) !== BigInt(a.asset)) - }) - - // Do accounts next because the account limit is 4 - g.accounts?.forEach((a) => { - populateGroupResource(group, a, 'account') - }) - - g.boxes?.forEach((b) => { - populateGroupResource(group, b, 'box') - - // Remove apps as resource from the group if we're adding it here - g.apps = g.apps?.filter((app) => BigInt(app) !== BigInt(b.app)) - }) - - g.assets?.forEach((a) => { - populateGroupResource(group, a, 'asset') - }) - - g.apps?.forEach((a) => { - populateGroupResource(group, a, 'app') - }) - - if (g.extraBoxRefs) { - for (let i = 0; i < g.extraBoxRefs; i += 1) { - const ref: BoxReference = { app: 0n, name: new Uint8Array(0) } - populateGroupResource(group, ref, 'box') - } - } - } + if (additionalAtcContext?.maxFees) { + newComposer.setMaxFees(additionalAtcContext?.maxFees) } - const newAtc = new algosdk.AtomicTransactionComposer() - - group.forEach((t) => { - t.txn.group = undefined - newAtc.addTransaction(t) - }) + await newComposer.build() - newAtc['methodCalls'] = atc['methodCalls'] - - return newAtc + return newComposer } /** - * Signs and sends transactions that have been collected by an `AtomicTransactionComposer`. - * @param atcSend The parameters controlling the send, including `atc` The `AtomicTransactionComposer` and params to control send behaviour + * @deprecated Use `composer.send()` directly + * Signs and sends transactions that have been collected by an `TransactionComposer`. + * @param atcSend The parameters controlling the send, including `atc` The `TransactionComposer` and params to control send behaviour * @param algod An algod client * @returns An object with transaction IDs, transactions, group transaction ID (`groupTransactionId`) if more than 1 transaction sent, and (if `skipWaiting` is `false` or unset) confirmation (`confirmation`) */ -export const sendAtomicTransactionComposer = async function (atcSend: AtomicTransactionComposerToSend, algod: AlgodClient) { - const { atc: givenAtc, sendParams, additionalAtcContext, ...executeParams } = atcSend - - let atc: AtomicTransactionComposer - - atc = givenAtc - try { - const transactionsWithSigner = atc.buildGroup() - - // If populateAppCallResources is true OR if populateAppCallResources is undefined and there are app calls, then populate resources - const populateAppCallResources = - executeParams?.populateAppCallResources ?? sendParams?.populateAppCallResources ?? Config.populateAppCallResources - const coverAppCallInnerTransactionFees = executeParams?.coverAppCallInnerTransactionFees - - if ( - (populateAppCallResources || coverAppCallInnerTransactionFees) && - transactionsWithSigner.map((t) => t.txn.type).includes(TransactionType.AppCall) - ) { - atc = await prepareGroupForSending( - givenAtc, - algod, - { ...executeParams, populateAppCallResources, coverAppCallInnerTransactionFees }, - additionalAtcContext, - ) - } - - // atc.buildGroup() is needed to ensure that any changes made by prepareGroupForSending are reflected and the group id is set - const transactionsToSend = atc.buildGroup().map((t) => { - return t.txn - }) - let groupId: string | undefined = undefined - if (transactionsToSend.length > 1) { - groupId = transactionsToSend[0].group ? Buffer.from(transactionsToSend[0].group).toString('base64') : '' - Config.getLogger(executeParams?.suppressLog ?? sendParams?.suppressLog).verbose( - `Sending group of ${transactionsToSend.length} transactions (${groupId})`, - { - transactionsToSend, - }, - ) - - Config.getLogger(executeParams?.suppressLog ?? sendParams?.suppressLog).debug( - `Transaction IDs (${groupId})`, - transactionsToSend.map((t) => getTransactionId(t)), - ) - } - - if (Config.debug && Config.traceAll) { - // Emit the simulate response for use with AlgoKit AVM debugger - const simulateResponse = await performAtomicTransactionComposerSimulate(atc, algod) - await Config.events.emitAsync(EventType.TxnGroupSimulated, { - simulateResponse, - }) - } - const result = await atc.execute( - algod, - executeParams?.maxRoundsToWaitForConfirmation ?? sendParams?.maxRoundsToWaitForConfirmation ?? 5, - ) - - if (transactionsToSend.length > 1) { - Config.getLogger(executeParams?.suppressLog ?? sendParams?.suppressLog).verbose( - `Group transaction (${groupId}) sent with ${transactionsToSend.length} transactions`, - ) - } else { - Config.getLogger(executeParams?.suppressLog ?? sendParams?.suppressLog).verbose( - `Sent transaction ID ${getTransactionId(transactionsToSend[0])} ${transactionsToSend[0].type} from ${transactionsToSend[0].sender}`, - ) - } - - let confirmations: PendingTransactionResponse[] | undefined = undefined - if (!sendParams?.skipWaiting) { - confirmations = await Promise.all(transactionsToSend.map(async (t) => await algod.pendingTransactionInformation(getTransactionId(t)))) - } - - const methodCalls = [...(atc['methodCalls'] as Map).values()] - - return { - groupId: groupId!, - confirmations: (confirmations ?? []).map(wrapPendingTransactionResponse), - txIds: transactionsToSend.map((t) => getTransactionId(t)), - transactions: transactionsToSend.map((t) => new TransactionWrapper(t)), - returns: result.methodResults.map((r, i) => getABIReturnValue(r, methodCalls[i]!.returns.type)), - } satisfies SendAtomicTransactionComposerResults - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - // TODO: PD - look into error handling here again, it's possible that we don't need this comment anymore - // Create a new error object so the stack trace is correct (algosdk throws an error with a more limited stack trace) - - const errorMessage = e.body?.message ?? e.message ?? 'Received error executing Atomic Transaction Composer' - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const err = new Error(errorMessage) as any - err.cause = e - if (typeof e === 'object') { - err.name = e.name - } - - if (Config.debug && typeof e === 'object') { - err.traces = [] - Config.getLogger(executeParams?.suppressLog ?? sendParams?.suppressLog).error( - 'Received error executing Atomic Transaction Composer and debug flag enabled; attempting simulation to get more information', - err, - ) - const simulate = await performAtomicTransactionComposerSimulate(atc, algod) - if (Config.debug && !Config.traceAll) { - // Emit the event only if traceAll: false, as it should have already been emitted above - await Config.events.emitAsync(EventType.TxnGroupSimulated, { - simulateResponse: simulate, - }) - } +export const sendTransactionComposer = async function (atcSend: TransactionComposerToSend): Promise { + const { transactionComposer: givenComposer, sendParams, ...executeParams } = atcSend - if (simulate && simulate.txnGroups[0].failedAt) { - for (const txn of simulate.txnGroups[0].txnResults) { - err.traces.push({ - trace: AlgorandSerializer.encode(txn.execTrace, SimulationTransactionExecTraceMeta, 'map'), - appBudget: txn.appBudgetConsumed, - logicSigBudget: txn.logicSigBudgetConsumed, - logs: txn.txnResult.logs, - message: simulate.txnGroups[0].failureMessage, - }) - } - } - } else { - Config.getLogger(executeParams?.suppressLog ?? sendParams?.suppressLog).error( - 'Received error executing Atomic Transaction Composer, for more information enable the debug flag', - err, - ) - } - - // Attach the sent transactions so we can use them in error transformers - err.sentTransactions = atc.buildGroup().map((t) => new TransactionWrapper(t.txn)) - throw err - } + return atcSend.transactionComposer.send({ + ...sendParams, + ...executeParams, + }) } /** @@ -996,7 +362,7 @@ export function getABIReturnValue(result: algosdk.ABIResult, type: ABIReturnType } /** - * @deprecated Use `TransactionComposer` (`algorand.newGroup()`) or `AtomicTransactionComposer` to construct and send group transactions instead. + * @deprecated Use `TransactionComposer` (`algorand.newGroup()`) to construct and send group transactions instead. * * Signs and sends a group of [up to 16](https://dev.algorand.co/concepts/transactions/atomic-txn-groups/#create-transactions) transactions to the chain * @@ -1006,7 +372,10 @@ export function getABIReturnValue(result: algosdk.ABIResult, type: ABIReturnType * @param algod An algod client * @returns An object with transaction IDs, transactions, group transaction ID (`groupTransactionId`) if more than 1 transaction sent, and (if `skipWaiting` is `false` or unset) confirmation (`confirmation`) */ -export const sendGroupOfTransactions = async function (groupSend: TransactionGroupToSend, algod: AlgodClient) { +export const sendGroupOfTransactions = async function ( + groupSend: TransactionGroupToSend, + algod: AlgodClient, +): Promise> { const { transactions, signer, sendParams } = groupSend const defaultTransactionSigner = signer ? getSenderTransactionSigner(signer) : undefined @@ -1017,7 +386,6 @@ export const sendGroupOfTransactions = async function (groupSend: TransactionGro return { txn: t.transaction, signer: getSenderTransactionSigner(t.signer), - sender: t.signer, } const txn = 'then' in t ? (await t).transaction : t @@ -1030,15 +398,20 @@ export const sendGroupOfTransactions = async function (groupSend: TransactionGro return { txn, signer: defaultTransactionSigner!, - sender: signer, } }), ) - const atc = new AtomicTransactionComposer() - transactionsWithSigner.forEach((txn) => atc.addTransaction(txn)) + const composer = new TransactionComposer({ + algod: algod, + getSigner: (address) => { + throw new Error(`No signer for address ${address}`) + }, + }) + transactionsWithSigner.forEach((txnWithSigner) => composer.addTransaction(txnWithSigner.txn, txnWithSigner.signer)) - return (await sendAtomicTransactionComposer({ atc, sendParams }, algod)) as Omit + const result = await composer.send(sendParams) + return result } /** @@ -1172,15 +545,15 @@ export async function getTransactionParams(params: SuggestedParams | undefined, } /** - * @deprecated Use `atc.clone().buildGroup()` instead. + * @deprecated Use `composer.clone().build()` instead. * - * Returns the array of transactions currently present in the given `AtomicTransactionComposer` - * @param atc The atomic transaction composer + * Returns the array of transactions currently present in the given `TransactionComposer` + * @param atc The transaction composer * @returns The array of transactions with signers */ -export function getAtomicTransactionComposerTransactions(atc: AtomicTransactionComposer) { +export async function getTransactionComposerTransactions(composer: TransactionComposer) { try { - return atc.clone().buildGroup() + return (await composer.clone().build()).transactions.map((transactionWithSigner) => transactionWithSigner.txn) } catch { return [] } diff --git a/src/transactions/app-call.ts b/src/transactions/app-call.ts new file mode 100644 index 00000000..ef164876 --- /dev/null +++ b/src/transactions/app-call.ts @@ -0,0 +1,600 @@ +import { + ApplicationLocalReference, + AssetHoldingReference, + SimulateUnnamedResourcesAccessed, +} from '@algorandfoundation/algokit-algod-client' +import { MAX_ACCOUNT_REFERENCES, MAX_OVERALL_REFERENCES, getAppAddress } from '@algorandfoundation/algokit-common' +import { + AccessReference, + OnApplicationComplete, + BoxReference as TransactBoxReference, + Transaction, + TransactionType, +} from '@algorandfoundation/algokit-transact' +import { Address } from '@algorandfoundation/sdk' +import { AppManager, BoxIdentifier, BoxReference as UtilsBoxReference } from '../types/app-manager' +import { Expand } from '../types/expand' +import { calculateExtraProgramPages } from '../util' +import { CommonTransactionParams, TransactionHeader } from './common' + +/** Common parameters for defining an application call transaction. */ +export type CommonAppCallParams = CommonTransactionParams & { + /** ID of the application; 0 if the application is being created. */ + appId: bigint + /** The [on-complete](https://dev.algorand.co/concepts/smart-contracts/avm#oncomplete) action of the call; defaults to no-op. */ + onComplete?: OnApplicationComplete + /** Any [arguments to pass to the smart contract call](/concepts/smart-contracts/languages/teal/#argument-passing). */ + args?: Uint8Array[] + /** Any account addresses to add to the [accounts array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). */ + accountReferences?: (string | Address)[] + /** The ID of any apps to load to the [foreign apps array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). */ + appReferences?: bigint[] + /** The ID of any assets to load to the [foreign assets array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). */ + assetReferences?: bigint[] + /** Any boxes to load to the [boxes array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). + * + * Either the name identifier (which will be set against app ID of `0` i.e. + * the current app), or a box identifier with the name identifier and app ID. + */ + boxReferences?: (UtilsBoxReference | BoxIdentifier)[] + /** Access references unifies `accountReferences`, `appReferences`, `assetReferences`, and `boxReferences` under a single list. If non-empty, these other reference lists must be empty. If access is empty, those other reference lists may be non-empty. */ + accessReferences?: AccessReference[] + /** The lowest application version for which this transaction should immediately fail. 0 indicates that no version check should be performed. */ + rejectVersion?: number +} + +/** Parameters to define an app create transaction */ +export type AppCreateParams = Expand< + Omit & { + onComplete?: Exclude + /** The program to execute for all OnCompletes other than ClearState as raw teal that will be compiled (string) or compiled teal (encoded as a byte array (Uint8Array)). */ + approvalProgram: string | Uint8Array + /** The program to execute for ClearState OnComplete as raw teal that will be compiled (string) or compiled teal (encoded as a byte array (Uint8Array)). */ + clearStateProgram: string | Uint8Array + /** The state schema for the app. This is immutable once the app is created. */ + schema?: { + /** The number of integers saved in global state. */ + globalInts: number + /** The number of byte slices saved in global state. */ + globalByteSlices: number + /** The number of integers saved in local state. */ + localInts: number + /** The number of byte slices saved in local state. */ + localByteSlices: number + } + /** Number of extra pages required for the programs. + * Defaults to the number needed for the programs in this call if not specified. + * This is immutable once the app is created. */ + extraProgramPages?: number + } +> + +/** Parameters to define an app update transaction */ +export type AppUpdateParams = Expand< + CommonAppCallParams & { + onComplete?: OnApplicationComplete.UpdateApplication + /** The program to execute for all OnCompletes other than ClearState as raw teal (string) or compiled teal (base 64 encoded as a byte array (Uint8Array)) */ + approvalProgram: string | Uint8Array + /** The program to execute for ClearState OnComplete as raw teal (string) or compiled teal (base 64 encoded as a byte array (Uint8Array)) */ + clearStateProgram: string | Uint8Array + } +> + +/** Parameters to define an application call transaction. */ +export type AppCallParams = CommonAppCallParams & { + onComplete?: Exclude +} + +/** Common parameters to define an ABI method call transaction. */ +export type AppMethodCallParams = CommonAppCallParams & { + onComplete?: Exclude +} + +/** Parameters to define an application delete call transaction. */ +export type AppDeleteParams = CommonAppCallParams & { + onComplete?: OnApplicationComplete.DeleteApplication +} + +export const buildAppCreate = async (params: AppCreateParams, appManager: AppManager, header: TransactionHeader): Promise => { + const approvalProgram = + typeof params.approvalProgram === 'string' + ? (await appManager.compileTeal(params.approvalProgram)).compiledBase64ToBytes + : params.approvalProgram + const clearStateProgram = + typeof params.clearStateProgram === 'string' + ? (await appManager.compileTeal(params.clearStateProgram)).compiledBase64ToBytes + : params.clearStateProgram + const globalStateSchema = + params.schema?.globalByteSlices !== undefined || params.schema?.globalInts !== undefined + ? { + numByteSlices: params.schema?.globalByteSlices ?? 0, + numUints: params.schema?.globalInts ?? 0, + } + : undefined + const localStateSchema = + params.schema?.localByteSlices !== undefined || params.schema?.localInts !== undefined + ? { + numByteSlices: params.schema?.localByteSlices ?? 0, + numUints: params.schema?.localInts ?? 0, + } + : undefined + const extraProgramPages = + params.extraProgramPages !== undefined ? params.extraProgramPages : calculateExtraProgramPages(approvalProgram!, clearStateProgram!) + + // If accessReferences is provided, we should not pass legacy foreign arrays + const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 + + return { + ...header, + type: TransactionType.AppCall, + appCall: { + appId: 0n, // App creation always uses ID 0 + onComplete: params.onComplete ?? OnApplicationComplete.NoOp, + approvalProgram: approvalProgram, + clearStateProgram: clearStateProgram, + globalStateSchema: globalStateSchema, + localStateSchema: localStateSchema, + extraProgramPages: extraProgramPages, + args: params.args, + ...(hasAccessReferences + ? { accessReferences: params.accessReferences } + : { + accountReferences: params.accountReferences?.map((a) => a.toString()), + appReferences: params.appReferences, + assetReferences: params.assetReferences, + boxReferences: params.boxReferences?.map(AppManager.getBoxReference), + }), + rejectVersion: params.rejectVersion, + }, + } satisfies Transaction +} + +export const buildAppUpdate = async (params: AppUpdateParams, appManager: AppManager, header: TransactionHeader): Promise => { + const approvalProgram = + typeof params.approvalProgram === 'string' + ? (await appManager.compileTeal(params.approvalProgram)).compiledBase64ToBytes + : params.approvalProgram + const clearStateProgram = + typeof params.clearStateProgram === 'string' + ? (await appManager.compileTeal(params.clearStateProgram)).compiledBase64ToBytes + : params.clearStateProgram + + // If accessReferences is provided, we should not pass legacy foreign arrays + const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 + + return { + ...header, + type: TransactionType.AppCall, + appCall: { + appId: params.appId, + onComplete: OnApplicationComplete.UpdateApplication, + approvalProgram: approvalProgram, + clearStateProgram: clearStateProgram, + args: params.args, + ...(hasAccessReferences + ? { accessReferences: params.accessReferences } + : { + accountReferences: params.accountReferences?.map((a) => a.toString()), + appReferences: params.appReferences, + assetReferences: params.assetReferences, + boxReferences: params.boxReferences?.map(AppManager.getBoxReference), + }), + rejectVersion: params.rejectVersion, + }, + } satisfies Transaction +} + +export const buildAppCall = (params: AppCallParams | AppDeleteParams, header: TransactionHeader): Transaction => { + // If accessReferences is provided, we should not pass legacy foreign arrays + const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 + + return { + ...header, + type: TransactionType.AppCall, + appCall: { + appId: params.appId, + onComplete: params.onComplete ?? OnApplicationComplete.NoOp, + args: params.args, + ...(hasAccessReferences + ? { accessReferences: params.accessReferences } + : { + accountReferences: params.accountReferences?.map((a) => a.toString()), + appReferences: params.appReferences, + assetReferences: params.assetReferences, + boxReferences: params.boxReferences?.map(AppManager.getBoxReference), + }), + rejectVersion: params.rejectVersion, + }, + } satisfies Transaction +} + +/** + * Populate transaction-level resources for app call transactions + */ +export function populateTransactionResources( + transaction: Transaction, // NOTE: transaction is mutated in place + resourcesAccessed: SimulateUnnamedResourcesAccessed, + groupIndex: number, +): void { + if (transaction.type !== TransactionType.AppCall || transaction.appCall === undefined) { + return + } + + // Check for unexpected resources at transaction level + if (resourcesAccessed.boxes || resourcesAccessed.extraBoxRefs) { + throw new Error('Unexpected boxes at the transaction level') + } + if (resourcesAccessed.appLocals) { + throw new Error('Unexpected app locals at the transaction level') + } + if (resourcesAccessed.assetHoldings) { + throw new Error('Unexpected asset holdings at the transaction level') + } + + let accountsCount = 0 + let appsCount = 0 + let assetsCount = 0 + const boxesCount = transaction.appCall.boxReferences?.length ?? 0 + + // Populate accounts + if (resourcesAccessed.accounts) { + transaction.appCall.accountReferences = transaction.appCall.accountReferences ?? [] + for (const account of resourcesAccessed.accounts) { + if (!transaction.appCall.accountReferences.includes(account)) { + transaction.appCall.accountReferences.push(account) + } + } + accountsCount = transaction.appCall.accountReferences.length + } + + // Populate apps + if (resourcesAccessed.apps) { + transaction.appCall.appReferences = transaction.appCall.appReferences ?? [] + for (const appId of resourcesAccessed.apps) { + if (!transaction.appCall.appReferences.includes(appId)) { + transaction.appCall.appReferences.push(appId) + } + } + appsCount = transaction.appCall.appReferences.length + } + + // Populate assets + if (resourcesAccessed.assets) { + transaction.appCall.assetReferences = transaction.appCall.assetReferences ?? [] + for (const assetId of resourcesAccessed.assets) { + if (!transaction.appCall.assetReferences.includes(assetId)) { + transaction.appCall.assetReferences.push(assetId) + } + } + assetsCount = transaction.appCall.assetReferences.length + } + + // Validate reference limits + if (accountsCount > MAX_ACCOUNT_REFERENCES) { + throw new Error(`Account reference limit of ${MAX_ACCOUNT_REFERENCES} exceeded in transaction ${groupIndex}`) + } + + if (accountsCount + assetsCount + appsCount + boxesCount > MAX_OVERALL_REFERENCES) { + throw new Error(`Resource reference limit of ${MAX_OVERALL_REFERENCES} exceeded in transaction ${groupIndex}`) + } +} + +enum GroupResourceType { + Account, + App, + Asset, + Box, + ExtraBoxRef, + AssetHolding, + AppLocal, +} + +/** + * Populate group-level resources for app call transactions + */ +export function populateGroupResources( + transactions: Transaction[], // NOTE: transactions are mutated in place + groupResources: SimulateUnnamedResourcesAccessed, +): void { + let remainingAccounts = [...(groupResources.accounts ?? [])] + let remainingApps = [...(groupResources.apps ?? [])] + let remainingAssets = [...(groupResources.assets ?? [])] + const remainingBoxes = [...(groupResources.boxes ?? [])] + + // Process cross-reference resources first (app locals and asset holdings) as they are most restrictive + if (groupResources.appLocals) { + groupResources.appLocals.forEach((appLocal) => { + populateGroupResource(transactions, { type: GroupResourceType.AppLocal, data: appLocal }) + // Remove resources from remaining if we're adding them here + remainingAccounts = remainingAccounts.filter((acc) => acc !== appLocal.account) + remainingApps = remainingApps.filter((app) => app !== appLocal.app) + }) + } + + if (groupResources.assetHoldings) { + groupResources.assetHoldings.forEach((assetHolding) => { + populateGroupResource(transactions, { type: GroupResourceType.AssetHolding, data: assetHolding }) + // Remove resources from remaining if we're adding them here + remainingAccounts = remainingAccounts.filter((acc) => acc !== assetHolding.account) + remainingAssets = remainingAssets.filter((asset) => asset !== assetHolding.asset) + }) + } + + // Process accounts next because account limit is 4 + remainingAccounts.forEach((account) => { + populateGroupResource(transactions, { type: GroupResourceType.Account, data: account }) + }) + + // Process boxes + remainingBoxes.forEach((boxRef) => { + populateGroupResource(transactions, { + type: GroupResourceType.Box, + data: { + appId: boxRef.app, + name: boxRef.name, + }, + }) + // Remove apps as resource if we're adding it here + remainingApps = remainingApps.filter((app) => app !== boxRef.app) + }) + + // Process assets + remainingAssets.forEach((asset) => { + populateGroupResource(transactions, { type: GroupResourceType.Asset, data: asset }) + }) + + // Process remaining apps + remainingApps.forEach((app) => { + populateGroupResource(transactions, { type: GroupResourceType.App, data: app }) + }) + + // Handle extra box refs + if (groupResources.extraBoxRefs) { + for (let i = 0; i < groupResources.extraBoxRefs; i++) { + populateGroupResource(transactions, { type: GroupResourceType.ExtraBoxRef }) + } + } +} + +/** + * Helper function to check if an app call transaction is below resource limit + */ +function isAppCallBelowResourceLimit(txn: Transaction): boolean { + if (txn.type !== TransactionType.AppCall) { + return false + } + if (txn.appCall?.accessReferences?.length) { + return false + } + + const accountsCount = txn.appCall?.accountReferences?.length || 0 + const assetsCount = txn.appCall?.assetReferences?.length || 0 + const appsCount = txn.appCall?.appReferences?.length || 0 + const boxesCount = txn.appCall?.boxReferences?.length || 0 + + return accountsCount + assetsCount + appsCount + boxesCount < MAX_OVERALL_REFERENCES +} + +type GroupResourceToPopulate = + | { type: GroupResourceType.Account; data: string } + | { type: GroupResourceType.App; data: bigint } + | { type: GroupResourceType.Asset; data: bigint } + | { type: GroupResourceType.Box; data: TransactBoxReference } + | { type: GroupResourceType.ExtraBoxRef } + | { type: GroupResourceType.AssetHolding; data: AssetHoldingReference } + | { type: GroupResourceType.AppLocal; data: ApplicationLocalReference } + +/** + * Helper function to populate a specific resource into a transaction group + */ +function populateGroupResource( + transactions: Transaction[], // NOTE: transactions are mutated in place + resource: GroupResourceToPopulate, +): void { + // For asset holdings and app locals, first try to find a transaction that already has the account available + if (resource.type === GroupResourceType.AssetHolding || resource.type === GroupResourceType.AppLocal) { + const account = resource.data.account + + // Try to find a transaction that already has the account available + const groupIndex1 = transactions.findIndex((txn) => { + if (!isAppCallBelowResourceLimit(txn)) { + return false + } + + const appCall = txn.appCall! + + // Check if account is in foreign accounts array + if (appCall.accountReferences?.includes(account)) { + return true + } + + // Check if account is available as an app account + if (appCall.appReferences) { + for (const appId of appCall.appReferences) { + if (account === getAppAddress(appId)) { + return true + } + } + } + + // Check if account appears in any app call transaction fields + if (txn.sender === account) { + return true + } + + return false + }) + + if (groupIndex1 !== -1) { + const appCall = transactions[groupIndex1].appCall! + if (resource.type === GroupResourceType.AssetHolding) { + appCall.assetReferences = appCall.assetReferences ?? [] + if (!appCall.assetReferences.includes(resource.data.asset)) { + appCall.assetReferences.push(resource.data.asset) + } + } else { + appCall.appReferences = appCall.appReferences ?? [] + if (!appCall.appReferences.includes(resource.data.app)) { + appCall.appReferences.push(resource.data.app) + } + } + return + } + + // Try to find a transaction that has the asset/app available and space for account + const groupIndex2 = transactions.findIndex((txn) => { + if (!isAppCallBelowResourceLimit(txn)) { + return false + } + + const appCall = txn.appCall! + if ((appCall.accountReferences?.length ?? 0) >= MAX_ACCOUNT_REFERENCES) { + return false + } + + if (resource.type === GroupResourceType.AssetHolding) { + return appCall.assetReferences?.includes(resource.data.asset) || false + } else { + return appCall.appReferences?.includes(resource.data.app) || appCall.appId === resource.data.app + } + }) + + if (groupIndex2 !== -1) { + const appCall = transactions[groupIndex2].appCall! + appCall.accountReferences = appCall.accountReferences ?? [] + if (!appCall.accountReferences.includes(account)) { + appCall.accountReferences.push(account) + } + return + } + } + + // For boxes, first try to find a transaction that already has the app available + if (resource.type === GroupResourceType.Box) { + const groupIndex = transactions.findIndex((txn) => { + if (!isAppCallBelowResourceLimit(txn)) { + return false + } + + const appCall = txn.appCall! + return appCall.appReferences?.includes(resource.data.appId) || appCall.appId === resource.data.appId + }) + + if (groupIndex !== -1) { + const appCall = transactions[groupIndex].appCall! + appCall.boxReferences = appCall.boxReferences ?? [] + const exists = appCall.boxReferences.some( + (b) => + b.appId === resource.data.appId && + b.name.length === resource.data.name.length && + b.name.every((byte, i) => byte === resource.data.name[i]), + ) + if (!exists) { + appCall.boxReferences.push({ appId: resource.data.appId, name: resource.data.name }) + } + return + } + } + + // Find the first transaction that can accommodate the resource + const groupIndex = transactions.findIndex((txn) => { + if (txn.type !== TransactionType.AppCall) { + return false + } + if (txn.appCall?.accessReferences?.length) { + return false + } + + const appCall = txn.appCall! + const accountsCount = appCall.accountReferences?.length ?? 0 + const assetsCount = appCall.assetReferences?.length ?? 0 + const appsCount = appCall.appReferences?.length ?? 0 + const boxesCount = appCall.boxReferences?.length ?? 0 + + switch (resource.type) { + case GroupResourceType.Account: + return accountsCount < MAX_ACCOUNT_REFERENCES + case GroupResourceType.AssetHolding: + case GroupResourceType.AppLocal: + return accountsCount + assetsCount + appsCount + boxesCount < MAX_OVERALL_REFERENCES - 1 && accountsCount < MAX_ACCOUNT_REFERENCES + case GroupResourceType.Box: + if (resource.data.appId !== 0n) { + return accountsCount + assetsCount + appsCount + boxesCount < MAX_OVERALL_REFERENCES - 1 + } else { + return accountsCount + assetsCount + appsCount + boxesCount < MAX_OVERALL_REFERENCES + } + default: + return accountsCount + assetsCount + appsCount + boxesCount < MAX_OVERALL_REFERENCES + } + }) + + if (groupIndex === -1) { + throw new Error('No more transactions below reference limit. Add another app call to the group.') + } + + const appCall = transactions[groupIndex].appCall! + + switch (resource.type) { + case GroupResourceType.Account: + appCall.accountReferences = appCall.accountReferences ?? [] + if (!appCall.accountReferences.includes(resource.data)) { + appCall.accountReferences.push(resource.data) + } + break + case GroupResourceType.App: + appCall.appReferences = appCall.appReferences ?? [] + if (!appCall.appReferences.includes(resource.data)) { + appCall.appReferences.push(resource.data) + } + break + case GroupResourceType.Box: { + appCall.boxReferences = appCall.boxReferences ?? [] + const exists = appCall.boxReferences.some( + (b) => + b.appId === resource.data.appId && + b.name.length === resource.data.name.length && + b.name.every((byte, i) => byte === resource.data.name[i]), + ) + if (!exists) { + appCall.boxReferences.push({ appId: resource.data.appId, name: resource.data.name }) + } + if (resource.data.appId !== 0n) { + appCall.appReferences = appCall.appReferences ?? [] + if (!appCall.appReferences.includes(resource.data.appId)) { + appCall.appReferences.push(resource.data.appId) + } + } + break + } + case GroupResourceType.ExtraBoxRef: + appCall.boxReferences = appCall.boxReferences ?? [] + appCall.boxReferences.push({ appId: 0n, name: new Uint8Array(0) }) + break + case GroupResourceType.AssetHolding: + appCall.assetReferences = appCall.assetReferences ?? [] + if (!appCall.assetReferences.includes(resource.data.asset)) { + appCall.assetReferences.push(resource.data.asset) + } + appCall.accountReferences = appCall.accountReferences ?? [] + if (!appCall.accountReferences.includes(resource.data.account)) { + appCall.accountReferences.push(resource.data.account) + } + break + case GroupResourceType.AppLocal: + appCall.appReferences = appCall.appReferences ?? [] + if (!appCall.appReferences.includes(resource.data.app)) { + appCall.appReferences.push(resource.data.app) + } + appCall.accountReferences = appCall.accountReferences ?? [] + if (!appCall.accountReferences.includes(resource.data.account)) { + appCall.accountReferences.push(resource.data.account) + } + break + case GroupResourceType.Asset: + appCall.assetReferences = appCall.assetReferences ?? [] + if (!appCall.assetReferences.includes(resource.data)) { + appCall.assetReferences.push(resource.data) + } + break + } +} diff --git a/src/transactions/asset-config.ts b/src/transactions/asset-config.ts new file mode 100644 index 00000000..752d3fbc --- /dev/null +++ b/src/transactions/asset-config.ts @@ -0,0 +1,243 @@ +import { Transaction, TransactionType } from '@algorandfoundation/algokit-transact' +import { Address } from '@algorandfoundation/sdk' +import { CommonTransactionParams, TransactionHeader, ensureString } from './common' + +/** Parameters to define an asset create transaction. + * + * The account that sends this transaction will automatically be opted in to the asset and will hold all units after creation. + */ +export type AssetCreateParams = CommonTransactionParams & { + /** The total amount of the smallest divisible (decimal) unit to create. + * + * For example, if `decimals` is, say, 2, then for every 100 `total` there would be 1 whole unit. + * + * This field can only be specified upon asset creation. + */ + total: bigint + + /** The amount of decimal places the asset should have. + * + * If unspecified then the asset will be in whole units (i.e. `0`). + * + * * If 0, the asset is not divisible; + * * If 1, the base unit of the asset is in tenths; + * * If 2, the base unit of the asset is in hundredths; + * * If 3, the base unit of the asset is in thousandths; + * * and so on up to 19 decimal places. + * + * This field can only be specified upon asset creation. + */ + decimals?: number + + /** The optional name of the asset. + * + * Max size is 32 bytes. + * + * This field can only be specified upon asset creation. + */ + assetName?: string + + /** The optional name of the unit of this asset (e.g. ticker name). + * + * Max size is 8 bytes. + * + * This field can only be specified upon asset creation. + */ + unitName?: string + + /** Specifies an optional URL where more information about the asset can be retrieved (e.g. metadata). + * + * Max size is 96 bytes. + * + * This field can only be specified upon asset creation. + */ + url?: string + + /** 32-byte hash of some metadata that is relevant to your asset and/or asset holders. + * + * The format of this metadata is up to the application. + * + * This field can only be specified upon asset creation. + */ + metadataHash?: string | Uint8Array + + /** Whether the asset is frozen by default for all accounts. + * Defaults to `false`. + * + * If `true` then for anyone apart from the creator to hold the + * asset it needs to be unfrozen per account using an asset freeze + * transaction from the `freeze` account, which must be set on creation. + * + * This field can only be specified upon asset creation. + */ + defaultFrozen?: boolean + + /** The address of the optional account that can manage the configuration of the asset and destroy it. + * + * The configuration fields it can change are `manager`, `reserve`, `clawback`, and `freeze`. + * + * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the `manager` the asset becomes permanently immutable. + */ + manager?: string | Address + + /** + * The address of the optional account that holds the reserve (uncirculated supply) units of the asset. + * + * This address has no specific authority in the protocol itself and is informational only. + * + * Some standards like [ARC-19](https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0019.md) + * rely on this field to hold meaningful data. + * + * It can be used in the case where you want to signal to holders of your asset that the uncirculated units + * of the asset reside in an account that is different from the default creator account. + * + * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the manager the field is permanently empty. + */ + reserve?: string | Address + + /** + * The address of the optional account that can be used to freeze or unfreeze holdings of this asset for any account. + * + * If empty, freezing is not permitted. + * + * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the manager the field is permanently empty. + */ + freeze?: string | Address + + /** + * The address of the optional account that can clawback holdings of this asset from any account. + * + * **This field should be used with caution** as the clawback account has the ability to **unconditionally take assets from any account**. + * + * If empty, clawback is not permitted. + * + * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the manager the field is permanently empty. + */ + clawback?: string | Address +} + +/** Parameters to define an asset reconfiguration transaction. + * + * **Note:** The manager, reserve, freeze, and clawback addresses + * are immutably empty if they are not set. If manager is not set then + * all fields are immutable from that point forward. + */ +export type AssetConfigParams = CommonTransactionParams & { + /** ID of the asset to reconfigure */ + assetId: bigint + /** The address of the optional account that can manage the configuration of the asset and destroy it. + * + * The configuration fields it can change are `manager`, `reserve`, `clawback`, and `freeze`. + * + * If not set (`undefined` or `""`) the asset will become permanently immutable. + */ + manager: string | Address | undefined + /** + * The address of the optional account that holds the reserve (uncirculated supply) units of the asset. + * + * This address has no specific authority in the protocol itself and is informational only. + * + * Some standards like [ARC-19](https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0019.md) + * rely on this field to hold meaningful data. + * + * It can be used in the case where you want to signal to holders of your asset that the uncirculated units + * of the asset reside in an account that is different from the default creator account. + * + * If not set (`undefined` or `""`) the field will become permanently empty. + */ + reserve?: string | Address + /** + * The address of the optional account that can be used to freeze or unfreeze holdings of this asset for any account. + * + * If empty, freezing is not permitted. + * + * If not set (`undefined` or `""`) the field will become permanently empty. + */ + freeze?: string | Address + /** + * The address of the optional account that can clawback holdings of this asset from any account. + * + * **This field should be used with caution** as the clawback account has the ability to **unconditionally take assets from any account**. + * + * If empty, clawback is not permitted. + * + * If not set (`undefined` or `""`) the field will become permanently empty. + */ + clawback?: string | Address +} + +/** Parameters to define an asset freeze transaction. */ +export type AssetFreezeParams = CommonTransactionParams & { + /** The ID of the asset to freeze/unfreeze */ + assetId: bigint + /** The address of the account to freeze or unfreeze */ + account: string | Address + /** Whether the assets in the account should be frozen */ + frozen: boolean +} + +/** Parameters to define an asset destroy transaction. + * + * Created assets can be destroyed only by the asset manager account. All of the assets must be owned by the creator of the asset before the asset can be deleted. + */ +export type AssetDestroyParams = CommonTransactionParams & { + /** ID of the asset to destroy */ + assetId: bigint +} + +export const buildAssetCreate = (params: AssetCreateParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetConfig, + assetConfig: { + assetId: 0n, // Asset creation always uses ID 0 + total: params.total, + decimals: params.decimals, + defaultFrozen: params.defaultFrozen, + assetName: params.assetName, + unitName: params.unitName, + url: params.url, + metadataHash: ensureString(params.metadataHash), + manager: params.manager?.toString(), + reserve: params.reserve?.toString(), + freeze: params.freeze?.toString(), + clawback: params.clawback?.toString(), + }, + } +} + +export const buildAssetConfig = (params: AssetConfigParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetConfig, + assetConfig: { + assetId: params.assetId, + manager: params.manager?.toString(), + reserve: params.reserve?.toString(), + freeze: params.freeze?.toString(), + clawback: params.clawback?.toString(), + }, + } +} + +export const buildAssetFreeze = (params: AssetFreezeParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetFreeze, + assetFreeze: { + assetId: params.assetId, + freezeTarget: params.account.toString(), + frozen: params.frozen, + }, + } +} + +export const buildAssetDestroy = (params: AssetDestroyParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetConfig, + assetConfig: { + assetId: params.assetId, + }, + } +} diff --git a/src/transactions/asset-transfer.ts b/src/transactions/asset-transfer.ts new file mode 100644 index 00000000..92af2ac0 --- /dev/null +++ b/src/transactions/asset-transfer.ts @@ -0,0 +1,81 @@ +import { Transaction, TransactionType } from '@algorandfoundation/algokit-transact' +import { Address } from '@algorandfoundation/sdk' +import { CommonTransactionParams, TransactionHeader } from './common' + +/** Parameters to define an asset transfer transaction. */ +export type AssetTransferParams = CommonTransactionParams & { + /** ID of the asset to transfer. */ + assetId: bigint + /** Amount of the asset to transfer (in smallest divisible (decimal) units). */ + amount: bigint + /** The address of the account that will receive the asset unit(s). */ + receiver: string | Address + /** Optional address of an account to clawback the asset from. + * + * Requires the sender to be the clawback account. + * + * **Warning:** Be careful with this parameter as it can lead to unexpected loss of funds if not used correctly. + */ + clawbackTarget?: string | Address + /** Optional address of an account to close the asset position to. + * + * **Warning:** Be careful with this parameter as it can lead to loss of funds if not used correctly. + */ + closeAssetTo?: string | Address +} + +/** Parameters to define an asset opt-in transaction. */ +export type AssetOptInParams = CommonTransactionParams & { + /** ID of the asset that will be opted-in to. */ + assetId: bigint +} + +/** Parameters to define an asset opt-out transaction. */ +export type AssetOptOutParams = CommonTransactionParams & { + /** ID of the asset that will be opted-out of. */ + assetId: bigint + /** + * The address of the asset creator account to close the asset + * position to (any remaining asset units will be sent to this account). + */ + creator: string | Address +} + +export const buildAssetTransfer = (params: AssetTransferParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetTransfer, + assetTransfer: { + assetId: params.assetId, + amount: params.amount, + receiver: params.receiver.toString(), + assetSender: params.clawbackTarget?.toString(), + closeRemainderTo: params.closeAssetTo?.toString(), + }, + } +} + +export const buildAssetOptIn = (params: AssetOptInParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetTransfer, + assetTransfer: { + assetId: params.assetId, + amount: 0n, + receiver: header.sender, + }, + } +} + +export const buildAssetOptOut = (params: AssetOptOutParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.AssetTransfer, + assetTransfer: { + assetId: params.assetId, + amount: 0n, + receiver: header.sender, + closeRemainderTo: params.creator?.toString(), + }, + } +} diff --git a/src/transactions/common.ts b/src/transactions/common.ts new file mode 100644 index 00000000..11a6b1ba --- /dev/null +++ b/src/transactions/common.ts @@ -0,0 +1,123 @@ +import { PendingTransactionResponse, SuggestedParams } from '@algorandfoundation/algokit-algod-client' +import { Address, TransactionSigner } from '@algorandfoundation/sdk' +import { encodeLease } from '../transaction' +import { TransactionSignerAccount } from '../types/account' +import { AlgoAmount } from '../types/amount' +import { FeeDelta } from './fee-coverage' + +/** Common parameters for defining a transaction. */ +export type CommonTransactionParams = { + /** The address of the account sending the transaction. */ + sender: string | Address + /** The function used to sign transaction(s); if not specified then + * an attempt will be made to find a registered signer for the + * given `sender` or use a default signer (if configured). + */ + signer?: TransactionSigner | TransactionSignerAccount + /** Change the signing key of the sender to the given address. + * + * **Warning:** Please be careful with this parameter and be sure to read the [official rekey guidance](https://dev.algorand.co/concepts/accounts/rekeying). + */ + rekeyTo?: string | Address + /** Note to attach to the transaction. Max of 1000 bytes. */ + note?: Uint8Array | string + /** Prevent multiple transactions with the same lease being included within the validity window. + * + * A [lease](https://dev.algorand.co/concepts/transactions/leases) + * enforces a mutually exclusive transaction (useful to prevent double-posting and other scenarios). + */ + lease?: Uint8Array | string + /** The static transaction fee. In most cases you want to use `extraFee` unless setting the fee to 0 to be covered by another transaction. */ + staticFee?: AlgoAmount + /** The fee to pay IN ADDITION to the suggested fee. Useful for manually covering inner transaction fees. */ + extraFee?: AlgoAmount + /** Throw an error if the fee for the transaction is more than this amount; prevents overspending on fees during high congestion periods. */ + maxFee?: AlgoAmount + /** How many rounds the transaction should be valid for, if not specified then the registered default validity window will be used. */ + validityWindow?: number | bigint + /** + * Set the first round this transaction is valid. + * If left undefined, the value from algod will be used. + * + * We recommend you only set this when you intentionally want this to be some time in the future. + */ + firstValidRound?: bigint + /** The last round this transaction is valid. It is recommended to use `validityWindow` instead. */ + lastValidRound?: bigint +} + +export type TransactionHeader = { + sender: string + fee?: bigint + firstValid: bigint + lastValid: bigint + genesisHash?: Uint8Array + genesisId?: string + note?: Uint8Array + rekeyTo?: string + lease?: Uint8Array + group?: Uint8Array +} + +export const ensureString = (data?: string | Uint8Array) => { + if (data === undefined) return undefined + const encoder = new TextEncoder() + return typeof data === 'string' ? encoder.encode(data) : data +} + +export const buildTransactionHeader = ( + commonParams: CommonTransactionParams, + suggestedParams: SuggestedParams, + defaultValidityWindow: bigint, +) => { + const firstValid = commonParams.firstValidRound ?? suggestedParams.firstValid + const lease = commonParams.lease === undefined ? undefined : encodeLease(commonParams.lease) + const note = ensureString(commonParams.note) + + return { + sender: commonParams.sender.toString(), + rekeyTo: commonParams.rekeyTo?.toString(), + note: note, + lease: lease, + fee: commonParams.staticFee?.microAlgos, + genesisId: suggestedParams.genesisId, + genesisHash: suggestedParams.genesisHash, + firstValid, + lastValid: + commonParams.lastValidRound ?? + (commonParams.validityWindow !== undefined ? firstValid + BigInt(commonParams.validityWindow) : firstValid + defaultValidityWindow), + group: undefined, + } satisfies TransactionHeader +} + +export function calculateInnerFeeDelta( + innerTransactions?: PendingTransactionResponse[], + minTransactionFee: bigint = 1000n, + acc?: FeeDelta, +): FeeDelta | undefined { + if (!innerTransactions) { + return acc + } + + // Surplus inner transaction fees do not pool up to the parent transaction. + // Additionally surplus inner transaction fees only pool from sibling transactions + // that are sent prior to a given inner transaction, hence why we iterate in reverse order. + return innerTransactions.reduceRight((acc, innerTxn) => { + const recursiveDelta = calculateInnerFeeDelta(innerTxn.innerTxns, minTransactionFee, acc) + + // Inner transactions don't require per byte fees + const txnFeeDelta = FeeDelta.fromBigInt(minTransactionFee - (innerTxn.txn.txn.fee ?? 0n)) + + const currentFeeDelta = FeeDelta.fromBigInt( + (recursiveDelta ? FeeDelta.toBigInt(recursiveDelta) : 0n) + (txnFeeDelta ? FeeDelta.toBigInt(txnFeeDelta) : 0n), + ) + + // If after the recursive inner fee calculations we have a surplus, + // return undefined to avoid pooling up surplus fees, which is not allowed. + if (currentFeeDelta && FeeDelta.isSurplus(currentFeeDelta)) { + return undefined + } + + return currentFeeDelta + }, acc) +} diff --git a/src/transactions/fee-coverage.spec.ts b/src/transactions/fee-coverage.spec.ts new file mode 100644 index 00000000..4f966693 --- /dev/null +++ b/src/transactions/fee-coverage.spec.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { FeeDelta, FeeDeltaType, FeePriority } from './fee-coverage' + +describe('FeeDelta', () => { + describe('fromBigInt', () => { + it('should create deficit for positive values', () => { + const delta = FeeDelta.fromBigInt(100n) + expect(delta).toEqual({ type: FeeDeltaType.Deficit, data: 100n }) + expect(FeeDelta.isDeficit(delta!)).toBe(true) + expect(FeeDelta.isSurplus(delta!)).toBe(false) + }) + + it('should create surplus for negative values', () => { + const delta = FeeDelta.fromBigInt(-50n) + expect(delta).toEqual({ type: FeeDeltaType.Surplus, data: 50n }) + expect(FeeDelta.isSurplus(delta!)).toBe(true) + expect(FeeDelta.isDeficit(delta!)).toBe(false) + }) + + it('should return undefined for zero values', () => { + const delta = FeeDelta.fromBigInt(0n) + expect(delta).toBeUndefined() + }) + }) + + describe('toBigInt', () => { + it('should convert deficit to positive bigint', () => { + const delta: FeeDelta = { type: FeeDeltaType.Deficit, data: 100n } + const result = FeeDelta.toBigInt(delta) + expect(result).toBe(100n) + }) + + it('should convert surplus to negative bigint', () => { + const delta: FeeDelta = { type: FeeDeltaType.Surplus, data: 50n } + const result = FeeDelta.toBigInt(delta) + expect(result).toBe(-50n) + }) + }) + + describe('amount', () => { + it('should return the amount for deficit', () => { + const delta: FeeDelta = { type: FeeDeltaType.Deficit, data: 100n } + expect(FeeDelta.amount(delta)).toBe(100n) + }) + + it('should return the amount for surplus', () => { + const delta: FeeDelta = { type: FeeDeltaType.Surplus, data: 50n } + expect(FeeDelta.amount(delta)).toBe(50n) + }) + }) + + describe('add', () => { + it('should add two deficits correctly', () => { + const delta1: FeeDelta = { type: FeeDeltaType.Deficit, data: 100n } + const delta2: FeeDelta = { type: FeeDeltaType.Deficit, data: 50n } + const result = FeeDelta.add(delta1, delta2) + expect(result).toEqual({ type: FeeDeltaType.Deficit, data: 150n }) + }) + + it('should add two surpluses correctly', () => { + const delta1: FeeDelta = { type: FeeDeltaType.Surplus, data: 100n } + const delta2: FeeDelta = { type: FeeDeltaType.Surplus, data: 50n } + const result = FeeDelta.add(delta1, delta2) + expect(result).toEqual({ type: FeeDeltaType.Surplus, data: 150n }) + }) + + it('should add deficit and surplus with deficit result', () => { + const deficit: FeeDelta = { type: FeeDeltaType.Deficit, data: 100n } + const surplus: FeeDelta = { type: FeeDeltaType.Surplus, data: 30n } + const result = FeeDelta.add(deficit, surplus) + expect(result).toEqual({ type: FeeDeltaType.Deficit, data: 70n }) + }) + + it('should add deficit and surplus with surplus result', () => { + const deficit: FeeDelta = { type: FeeDeltaType.Deficit, data: 30n } + const surplus: FeeDelta = { type: FeeDeltaType.Surplus, data: 100n } + const result = FeeDelta.add(deficit, surplus) + expect(result).toEqual({ type: FeeDeltaType.Surplus, data: 70n }) + }) + + it('should return undefined when deltas cancel out', () => { + const deficit: FeeDelta = { type: FeeDeltaType.Deficit, data: 50n } + const surplus: FeeDelta = { type: FeeDeltaType.Surplus, data: 50n } + const result = FeeDelta.add(deficit, surplus) + expect(result).toBeUndefined() + }) + }) +}) + +describe('FeePriority', () => { + describe('fee priority ordering', () => { + it('should order priorities correctly based on type and deficit amounts', () => { + const covered = FeePriority.Covered + const modifiableSmall = FeePriority.ModifiableDeficit(100n) + const modifiableLarge = FeePriority.ModifiableDeficit(1000n) + const immutableSmall = FeePriority.ImmutableDeficit(100n) + const immutableLarge = FeePriority.ImmutableDeficit(1000n) + + // Basic ordering, ImmutableDeficit > ModifiableDeficit > Covered + expect(immutableSmall.compare(modifiableLarge)).toBeGreaterThan(0) + expect(modifiableSmall.compare(covered)).toBeGreaterThan(0) + expect(immutableLarge.compare(modifiableLarge)).toBeGreaterThan(0) + + // Within same priority class, larger deficits have higher priority + expect(immutableLarge.compare(immutableSmall)).toBeGreaterThan(0) + expect(modifiableLarge.compare(modifiableSmall)).toBeGreaterThan(0) + }) + + it('should sort priorities in descending order correctly', () => { + const covered = FeePriority.Covered + const modifiableSmall = FeePriority.ModifiableDeficit(100n) + const modifiableLarge = FeePriority.ModifiableDeficit(1000n) + const immutableSmall = FeePriority.ImmutableDeficit(100n) + const immutableLarge = FeePriority.ImmutableDeficit(1000n) + + // Create a sorted array to verify the ordering behavior + const priorities = [covered, modifiableSmall, immutableSmall, modifiableLarge, immutableLarge] + + // Sort in descending order (highest priority first) + priorities.sort((a, b) => b.compare(a)) + + expect(priorities[0]).toEqual(FeePriority.ImmutableDeficit(1000n)) + expect(priorities[1]).toEqual(FeePriority.ImmutableDeficit(100n)) + expect(priorities[2]).toEqual(FeePriority.ModifiableDeficit(1000n)) + expect(priorities[3]).toEqual(FeePriority.ModifiableDeficit(100n)) + expect(priorities[4]).toEqual(FeePriority.Covered) + }) + + it('should handle equality correctly', () => { + const covered1 = FeePriority.Covered + const covered2 = FeePriority.Covered + const modifiable1 = FeePriority.ModifiableDeficit(100n) + const modifiable2 = FeePriority.ModifiableDeficit(100n) + const immutable1 = FeePriority.ImmutableDeficit(500n) + const immutable2 = FeePriority.ImmutableDeficit(500n) + + expect(covered1.equals(covered2)).toBe(true) + expect(modifiable1.equals(modifiable2)).toBe(true) + expect(immutable1.equals(immutable2)).toBe(true) + + expect(covered1.compare(covered2)).toBe(0) + expect(modifiable1.compare(modifiable2)).toBe(0) + expect(immutable1.compare(immutable2)).toBe(0) + }) + + it('should get priority type correctly', () => { + const covered = FeePriority.Covered + const modifiableDeficit = FeePriority.ModifiableDeficit(100n) + const immutableDeficit = FeePriority.ImmutableDeficit(100n) + + expect(covered.getPriorityType()).toBe(0) + expect(modifiableDeficit.getPriorityType()).toBe(1) + expect(immutableDeficit.getPriorityType()).toBe(2) + }) + + it('should get deficit amount correctly', () => { + const covered = FeePriority.Covered + const modifiableDeficit = FeePriority.ModifiableDeficit(250n) + const immutableDeficit = FeePriority.ImmutableDeficit(750n) + + expect(covered.getDeficitAmount()).toBe(0n) + expect(modifiableDeficit.getDeficitAmount()).toBe(250n) + expect(immutableDeficit.getDeficitAmount()).toBe(750n) + }) + }) +}) diff --git a/src/transactions/fee-coverage.ts b/src/transactions/fee-coverage.ts new file mode 100644 index 00000000..159c20b2 --- /dev/null +++ b/src/transactions/fee-coverage.ts @@ -0,0 +1,134 @@ +export enum FeeDeltaType { + Deficit, + Surplus, +} + +export type DeficitFeeDelta = { + type: FeeDeltaType.Deficit + data: bigint +} + +export type SurplusFeeDelta = { + type: FeeDeltaType.Surplus + data: bigint +} + +export type FeeDelta = DeficitFeeDelta | SurplusFeeDelta +export const FeeDelta = { + fromBigInt(value: bigint): FeeDelta | undefined { + if (value > 0n) { + return { type: FeeDeltaType.Deficit, data: value } + } else if (value < 0n) { + return { type: FeeDeltaType.Surplus, data: -value } + } + return undefined + }, + toBigInt(delta: FeeDelta): bigint { + return delta.type === FeeDeltaType.Deficit ? delta.data : -delta.data + }, + isDeficit(delta: FeeDelta): boolean { + return delta.type === FeeDeltaType.Deficit + }, + isSurplus(delta: FeeDelta): boolean { + return delta.type === FeeDeltaType.Surplus + }, + amount(delta: FeeDelta): bigint { + return delta.data + }, + add(lhs: FeeDelta, rhs: FeeDelta): FeeDelta | undefined { + return FeeDelta.fromBigInt(FeeDelta.toBigInt(lhs) + FeeDelta.toBigInt(rhs)) + }, +} + +class CoveredPriority { + getPriorityType(): number { + return 0 + } + + getDeficitAmount(): bigint { + return 0n + } + + compare(other: FeePriority): number { + const typeDiff = this.getPriorityType() - other.getPriorityType() + if (typeDiff !== 0) { + return typeDiff + } + // For same type (which can only be Covered), they're equal + return 0 + } + + equals(other: FeePriority): boolean { + return other instanceof CoveredPriority + } +} + +class ModifiableDeficitPriority { + constructor(public readonly deficit: bigint) {} + + getPriorityType(): number { + return 1 + } + + getDeficitAmount(): bigint { + return this.deficit + } + + compare(other: FeePriority): number { + const typeDiff = this.getPriorityType() - other.getPriorityType() + if (typeDiff !== 0) { + return typeDiff + } + // For same type, compare deficit amounts (larger deficit = higher priority) + if (other instanceof ModifiableDeficitPriority) { + return Number(this.deficit - other.deficit) + } + return 0 + } + + equals(other: FeePriority): boolean { + return other instanceof ModifiableDeficitPriority && this.deficit === other.deficit + } +} + +class ImmutableDeficitPriority { + constructor(public readonly deficit: bigint) {} + + getPriorityType(): number { + return 2 + } + + getDeficitAmount(): bigint { + return this.deficit + } + + compare(other: FeePriority): number { + const typeDiff = this.getPriorityType() - other.getPriorityType() + if (typeDiff !== 0) { + return typeDiff + } + // For same type, compare deficit amounts (larger deficit = higher priority) + if (other instanceof ImmutableDeficitPriority) { + return Number(this.deficit - other.deficit) + } + return 0 + } + + equals(other: FeePriority): boolean { + return other instanceof ImmutableDeficitPriority && this.deficit === other.deficit + } +} + +// Priority levels for fee coverage with deficit amounts +// ImmutableDeficit > ModifiableDeficit > Covered +// Within same priority type, larger deficits have higher priority +export type FeePriority = CoveredPriority | ModifiableDeficitPriority | ImmutableDeficitPriority +export const FeePriority = { + Covered: new CoveredPriority(), + ModifiableDeficit(deficit: bigint): ModifiableDeficitPriority { + return new ModifiableDeficitPriority(deficit) + }, + ImmutableDeficit(deficit: bigint): ImmutableDeficitPriority { + return new ImmutableDeficitPriority(deficit) + }, +} as const diff --git a/src/transactions/key-registration.ts b/src/transactions/key-registration.ts new file mode 100644 index 00000000..dffa3ca7 --- /dev/null +++ b/src/transactions/key-registration.ts @@ -0,0 +1,50 @@ +import { Transaction, TransactionType } from '@algorandfoundation/algokit-transact' +import { CommonTransactionParams, TransactionHeader } from './common' + +/** Parameters to define an online key registration transaction. */ +export type OnlineKeyRegistrationParams = CommonTransactionParams & { + /** The root participation public key */ + voteKey: Uint8Array + /** The VRF public key */ + selectionKey: Uint8Array + /** The first round that the participation key is valid. Not to be confused with the `firstValid` round of the keyreg transaction */ + voteFirst: bigint + /** The last round that the participation key is valid. Not to be confused with the `lastValid` round of the keyreg transaction */ + voteLast: bigint + /** This is the dilution for the 2-level participation key. It determines the interval (number of rounds) for generating new ephemeral keys */ + voteKeyDilution: bigint + /** The 64 byte state proof public key commitment */ + stateProofKey?: Uint8Array +} + +/** Parameters to define an offline key registration transaction. */ +export type OfflineKeyRegistrationParams = CommonTransactionParams & { + /** Prevent this account from ever participating again. The account will also no longer earn rewards */ + preventAccountFromEverParticipatingAgain?: boolean +} + +export const buildKeyReg = (params: OnlineKeyRegistrationParams | OfflineKeyRegistrationParams, header: TransactionHeader): Transaction => { + if ('voteKey' in params) { + return { + ...header, + type: TransactionType.KeyRegistration, + keyRegistration: { + voteKey: params.voteKey, + selectionKey: params.selectionKey, + voteFirst: params.voteFirst, + voteLast: params.voteLast, + voteKeyDilution: params.voteKeyDilution, + nonParticipation: false, + stateProofKey: params.stateProofKey, + }, + } + } else { + return { + ...header, + type: TransactionType.KeyRegistration, + keyRegistration: { + nonParticipation: params.preventAccountFromEverParticipatingAgain, + }, + } + } +} diff --git a/src/transactions/method-call.ts b/src/transactions/method-call.ts new file mode 100644 index 00000000..aaeb01a5 --- /dev/null +++ b/src/transactions/method-call.ts @@ -0,0 +1,617 @@ +import { OnApplicationComplete, Transaction, TransactionType } from '@algorandfoundation/algokit-transact' +import { + ABIMethod, + ABIReferenceType, + ABITupleType, + ABIType, + ABIUintType, + ABIValue, + Address, + TransactionSigner, + abiTypeIsReference, + abiTypeIsTransaction, +} from '@algorandfoundation/sdk' +import { TransactionWithSigner } from '../transaction' +import { AlgoAmount } from '../types/amount' +import { AppManager } from '../types/app-manager' +import { Expand } from '../types/expand' +import { calculateExtraProgramPages } from '../util' +import { AppCreateParams, AppDeleteParams, AppMethodCallParams, AppUpdateParams } from './app-call' +import { TransactionHeader } from './common' + +const ARGS_TUPLE_PACKING_THRESHOLD = 14 // 14+ args trigger tuple packing, excluding the method selector + +/** Parameters to define an ABI method call create transaction. */ +export type AppCreateMethodCall = Expand> +/** Parameters to define an ABI method call update transaction. */ +export type AppUpdateMethodCall = Expand> +/** Parameters to define an ABI method call delete transaction. */ +export type AppDeleteMethodCall = Expand> +/** Parameters to define an ABI method call transaction. */ +export type AppCallMethodCall = Expand> + +export type ProcessedAppCreateMethodCall = Expand< + Omit & { + args?: (ABIValue | undefined)[] + } +> + +export type ProcessedAppUpdateMethodCall = Expand< + Omit & { + args?: (ABIValue | undefined)[] + } +> + +export type ProcessedAppCallMethodCall = Expand< + Omit & { + args?: (ABIValue | undefined)[] + } +> + +/** Types that can be used to define a transaction argument for an ABI call transaction. */ +export type AppMethodCallTransactionArgument = + // The following should match the partial `args` types from `AppMethodCall` below + | TransactionWithSigner + | Transaction + | Promise + | AppMethodCall + | AppMethodCall + | AppMethodCall + +/** Parameters to define an ABI method call. */ +export type AppMethodCall = Expand> & { + /** The ABI method to call */ + method: ABIMethod + /** Arguments to the ABI method, either: + * * An ABI value + * * A transaction with explicit signer + * * A transaction (where the signer will be automatically assigned) + * * An unawaited transaction (e.g. from algorand.createTransaction.{transactionType}()) + * * Another method call (via method call params object) + * * undefined (this represents a placeholder transaction argument that is fulfilled by another method call argument) + */ + args?: ( + | ABIValue + // The following should match the above `AppMethodCallTransactionArgument` type above + | TransactionWithSigner + | Transaction + | Promise + | AppMethodCall + | AppMethodCall + | AppMethodCall + | undefined + )[] +} + +type AppMethodCallArgs = AppMethodCall['args'] +type AppMethodCallArg = NonNullable[number] + +export type AsyncTransactionParams = { + txn: Promise + signer?: TransactionSigner + maxFee?: AlgoAmount +} + +export type TransactionParams = { + txn: Transaction + signer?: TransactionSigner + maxFee?: AlgoAmount +} + +type ExtractedMethodCallTransactionArg = + | { data: TransactionParams; type: 'txn' } + | { + data: AsyncTransactionParams + type: 'asyncTxn' + } + | { data: ProcessedAppCallMethodCall | ProcessedAppCreateMethodCall | ProcessedAppUpdateMethodCall; type: 'methodCall' } + +export function extractComposerTransactionsFromAppMethodCallParams( + params: AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall | AppDeleteMethodCall, + parentSigner?: TransactionSigner, +): ExtractedMethodCallTransactionArg[] { + const composerTransactions = new Array() + const methodCallArgs = params.args + if (!methodCallArgs) return [] + + // Extract signer from params, falling back to parentSigner + const currentSigner = params.signer ? ('signer' in params.signer ? params.signer.signer : params.signer) : parentSigner + + for (let i = 0; i < methodCallArgs.length; i++) { + const arg = methodCallArgs[i] + + if (arg === undefined) { + // is a transaction or default value placeholder, do nothing + continue + } + if (isAbiValue(arg)) { + // if is ABI value, also ignore + continue + } + + if (isTransactionWithSignerArg(arg)) { + composerTransactions.push({ + data: { + txn: arg.txn, + signer: arg.signer, + }, + type: 'txn', + }) + + continue + } + if (isAppCallMethodCallArg(arg)) { + // Recursively extract nested method call transactions, passing the nested call itself and current signer as parent + const nestedComposerTransactions = extractComposerTransactionsFromAppMethodCallParams(arg, currentSigner) + composerTransactions.push(...nestedComposerTransactions) + composerTransactions.push({ + data: { + ...arg, + signer: arg.signer ?? currentSigner, + args: processAppMethodCallArgs(arg.args), + }, + type: 'methodCall', + } satisfies ExtractedMethodCallTransactionArg) + + continue + } + if (arg instanceof Promise) { + composerTransactions.push({ + data: { + txn: arg, + signer: currentSigner, + }, + type: 'asyncTxn', + }) + continue + } + + composerTransactions.push({ + data: { + txn: Promise.resolve(arg), + signer: currentSigner, + }, + type: 'asyncTxn', + }) + } + + return composerTransactions +} + +export function processAppMethodCallArgs(args: AppMethodCallArg[] | undefined): (ABIValue | undefined)[] | undefined { + if (args === undefined) return undefined + + return args.map((arg) => { + if (arg === undefined) { + // Handle explicit placeholders (either transaction or default value) + return undefined + } else if (!isAbiValue(arg)) { + // If the arg is not an ABIValue, it's must be a transaction, set to undefined + // transaction arguments should be flattened out and added into the composer during the add process + return undefined + } + return arg + }) +} + +function isTransactionWithSignerArg(arg: AppMethodCallArg): arg is TransactionWithSigner { + return typeof arg === 'object' && arg !== undefined && 'txn' in arg && 'signer' in arg +} + +function isAppCallMethodCallArg( + arg: AppMethodCallArg, +): arg is AppMethodCall | AppMethodCall | AppMethodCall { + return typeof arg === 'object' && arg !== undefined && 'method' in arg +} + +const isAbiValue = (x: unknown): x is ABIValue => { + if (Array.isArray(x)) return x.length == 0 || x.every(isAbiValue) + + return ( + typeof x === 'bigint' || + typeof x === 'boolean' || + typeof x === 'number' || + typeof x === 'string' || + x instanceof Uint8Array || + x instanceof Address + ) +} + +/** + * Populate reference arrays from processed ABI method call arguments + */ +function populateMethodArgsIntoReferenceArrays( + sender: string, + appId: bigint, + method: ABIMethod, + methodArgs: AppMethodCallArg[], + accountReferences?: string[], + appReferences?: bigint[], + assetReferences?: bigint[], +): { accountReferences: string[]; appReferences: bigint[]; assetReferences: bigint[] } { + const accounts = accountReferences ?? [] + const assets = assetReferences ?? [] + const apps = appReferences ?? [] + + methodArgs.forEach((arg, i) => { + const argType = method.args[i].type + if (abiTypeIsReference(argType)) { + switch (argType) { + case 'account': + if (typeof arg === 'string' && arg !== sender && !accounts.includes(arg)) { + accounts.push(arg) + } + break + case 'asset': + if (typeof arg === 'bigint' && !assets.includes(arg)) { + assets.push(arg) + } + break + case 'application': + if (typeof arg === 'bigint' && arg !== appId && !apps.includes(arg)) { + apps.push(arg) + } + break + } + } + }) + + return { accountReferences: accounts, appReferences: apps, assetReferences: assets } +} + +/** + * Calculate array index for ABI reference values + */ +function calculateMethodArgReferenceArrayIndex( + refValue: string | bigint, + referenceType: ABIReferenceType, + sender: string, + appId: bigint, + accountReferences: string[], + appReferences: bigint[], + assetReferences: bigint[], +): number { + switch (referenceType) { + case 'account': + if (typeof refValue === 'string') { + // If address is the same as sender, use index 0 + if (refValue === sender) return 0 + const index = accountReferences.indexOf(refValue) + if (index === -1) throw new Error(`Account ${refValue} not found in reference array`) + return index + 1 + } + throw new Error('Account reference must be a string') + case 'asset': + if (typeof refValue === 'bigint') { + const index = assetReferences.indexOf(refValue) + if (index === -1) throw new Error(`Asset ${refValue} not found in reference array`) + return index + } + throw new Error('Asset reference must be a bigint') + case 'application': + if (typeof refValue === 'bigint') { + // If app ID is the same as the current app, use index 0 + if (refValue === appId) return 0 + const index = appReferences.indexOf(refValue) + if (index === -1) throw new Error(`Application ${refValue} not found in reference array`) + return index + 1 + } + throw new Error('Application reference must be a bigint') + default: + throw new Error(`Unknown reference type: ${referenceType}`) + } +} + +/** + * Encode ABI method arguments with tuple packing support + * Ports the logic from the Rust encode_method_arguments function + */ +function encodeMethodArguments( + method: ABIMethod, + args: (ABIValue | undefined)[], + sender: string, + appId: bigint, + accountReferences: string[], + appReferences: bigint[], + assetReferences: bigint[], +): Uint8Array[] { + const encodedArgs = new Array() + + // Insert method selector at the front + encodedArgs.push(method.getSelector()) + + // Get ABI types for non-transaction arguments + const abiTypes = new Array() + const abiValues = new Array() + + // Process each method argument + for (let i = 0; i < method.args.length; i++) { + const methodArg = method.args[i] + const argValue = args[i] + + if (abiTypeIsTransaction(methodArg.type)) { + // Transaction arguments are not ABI encoded - they're handled separately + } else if (abiTypeIsReference(methodArg.type)) { + // Reference types are encoded as uint8 indexes + const referenceType = methodArg.type + if (typeof argValue === 'string' || typeof argValue === 'bigint') { + const foreignIndex = calculateMethodArgReferenceArrayIndex( + argValue, + referenceType, + sender, + appId, + accountReferences, + appReferences, + assetReferences, + ) + + abiTypes.push(new ABIUintType(8)) + abiValues.push(foreignIndex) + } else { + throw new Error(`Invalid reference value for ${referenceType}: ${argValue}`) + } + } else if (argValue !== undefined) { + // Regular ABI value + abiTypes.push(methodArg.type) + // it's safe to cast to ABIValue here because the abiType must be ABIValue + abiValues.push(argValue as ABIValue) + } + + // Skip undefined values (transaction placeholders) + } + + if (abiValues.length !== abiTypes.length) { + throw new Error('Mismatch in length of non-transaction arguments') + } + + // Apply ARC-4 tuple packing for methods with more than 14 arguments + // 14 instead of 15 in the ARC-4 because the first argument (method selector) is added separately + if (abiTypes.length > ARGS_TUPLE_PACKING_THRESHOLD) { + encodedArgs.push(...encodeArgsWithTuplePacking(abiTypes, abiValues)) + } else { + encodedArgs.push(...encodeArgsIndividually(abiTypes, abiValues)) + } + + return encodedArgs +} + +/** + * Encode individual ABI values + */ +function encodeArgsIndividually(abiTypes: ABIType[], abiValues: ABIValue[]): Uint8Array[] { + const encodedArgs: Uint8Array[] = [] + + for (let i = 0; i < abiTypes.length; i++) { + const abiType = abiTypes[i] + const abiValue = abiValues[i] + const encoded = abiType.encode(abiValue) + encodedArgs.push(encoded) + } + + return encodedArgs +} + +/** + * Encode ABI values with tuple packing for methods with many arguments + */ +function encodeArgsWithTuplePacking(abiTypes: ABIType[], abiValues: ABIValue[]): Uint8Array[] { + const encodedArgs: Uint8Array[] = [] + + // Encode first 14 arguments individually + const first14AbiTypes = abiTypes.slice(0, ARGS_TUPLE_PACKING_THRESHOLD) + const first14AbiValues = abiValues.slice(0, ARGS_TUPLE_PACKING_THRESHOLD) + encodedArgs.push(...encodeArgsIndividually(first14AbiTypes, first14AbiValues)) + + // Pack remaining arguments into tuple at position 15 + const remainingAbiTypes = abiTypes.slice(ARGS_TUPLE_PACKING_THRESHOLD) + const remainingAbiValues = abiValues.slice(ARGS_TUPLE_PACKING_THRESHOLD) + + if (remainingAbiTypes.length > 0) { + const tupleType = new ABITupleType(remainingAbiTypes) + const tupleValue = remainingAbiValues + const tupleEncoded = tupleType.encode(tupleValue) + encodedArgs.push(tupleEncoded) + } + + return encodedArgs +} + +/** + * Common method call building logic + */ +function buildMethodCallCommon( + params: { + appId: bigint + method: ABIMethod + args: (ABIValue | undefined)[] + accountReferences?: string[] + appReferences?: bigint[] + assetReferences?: bigint[] + }, + header: TransactionHeader, +): { args: Uint8Array[]; accountReferences: string[]; appReferences: bigint[]; assetReferences: bigint[] } { + const { accountReferences, appReferences, assetReferences } = populateMethodArgsIntoReferenceArrays( + header.sender, + params.appId, + params.method, + params.args ?? [], + params.accountReferences, + params.appReferences, + params.assetReferences, + ) + + const encodedArgs = encodeMethodArguments( + params.method, + params.args, + header.sender, + params.appId, + accountReferences, + appReferences, + assetReferences, + ) + + return { + args: encodedArgs, + accountReferences, + appReferences, + assetReferences, + } +} + +export const buildAppCreateMethodCall = async ( + params: ProcessedAppCreateMethodCall, + appManager: AppManager, + header: TransactionHeader, +): Promise => { + const approvalProgram = + typeof params.approvalProgram === 'string' + ? (await appManager.compileTeal(params.approvalProgram)).compiledBase64ToBytes + : params.approvalProgram + const clearStateProgram = + typeof params.clearStateProgram === 'string' + ? (await appManager.compileTeal(params.clearStateProgram)).compiledBase64ToBytes + : params.clearStateProgram + const globalStateSchema = + params.schema?.globalByteSlices !== undefined || params.schema?.globalInts !== undefined + ? { + numByteSlices: params.schema?.globalByteSlices ?? 0, + numUints: params.schema?.globalInts ?? 0, + } + : undefined + const localStateSchema = + params.schema?.localByteSlices !== undefined || params.schema?.localInts !== undefined + ? { + numByteSlices: params.schema?.localByteSlices ?? 0, + numUints: params.schema?.localInts ?? 0, + } + : undefined + const extraProgramPages = + params.extraProgramPages !== undefined ? params.extraProgramPages : calculateExtraProgramPages(approvalProgram!, clearStateProgram!) + const accountReferences = params.accountReferences?.map((a) => a.toString()) + const common = buildMethodCallCommon( + { + appId: 0n, + method: params.method, + args: params.args ?? [], + accountReferences: accountReferences, + appReferences: params.appReferences, + assetReferences: params.assetReferences, + }, + header, + ) + + // If accessReferences is provided, we should not pass legacy foreign arrays + const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 + + return { + ...header, + type: TransactionType.AppCall, + appCall: { + appId: 0n, + onComplete: params.onComplete ?? OnApplicationComplete.NoOp, + approvalProgram: approvalProgram, + clearStateProgram: clearStateProgram, + globalStateSchema: globalStateSchema, + localStateSchema: localStateSchema, + extraProgramPages: extraProgramPages, + args: common.args, + ...(hasAccessReferences + ? { accessReferences: params.accessReferences } + : { + accountReferences: params.accountReferences?.map((a) => a.toString()), + appReferences: params.appReferences, + assetReferences: params.assetReferences, + boxReferences: params.boxReferences?.map(AppManager.getBoxReference), + }), + rejectVersion: params.rejectVersion, + }, + } satisfies Transaction +} + +export const buildAppUpdateMethodCall = async ( + params: ProcessedAppUpdateMethodCall, + appManager: AppManager, + header: TransactionHeader, +): Promise => { + const approvalProgram = + typeof params.approvalProgram === 'string' + ? (await appManager.compileTeal(params.approvalProgram)).compiledBase64ToBytes + : params.approvalProgram + const clearStateProgram = + typeof params.clearStateProgram === 'string' + ? (await appManager.compileTeal(params.clearStateProgram)).compiledBase64ToBytes + : params.clearStateProgram + const accountReferences = params.accountReferences?.map((a) => a.toString()) + const common = buildMethodCallCommon( + { + appId: 0n, + method: params.method, + args: params.args ?? [], + accountReferences: accountReferences, + appReferences: params.appReferences, + assetReferences: params.assetReferences, + }, + header, + ) + + // If accessReferences is provided, we should not pass legacy foreign arrays + const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 + + return { + ...header, + type: TransactionType.AppCall, + appCall: { + appId: params.appId, + onComplete: OnApplicationComplete.UpdateApplication, + approvalProgram: approvalProgram, + clearStateProgram: clearStateProgram, + args: common.args, + ...(hasAccessReferences + ? { access: params.accessReferences } + : { + accountReferences: params.accountReferences?.map((a) => a.toString()), + appReferences: params.appReferences, + assetReferences: params.assetReferences, + boxReferences: params.boxReferences?.map(AppManager.getBoxReference), + }), + rejectVersion: params.rejectVersion, + }, + } +} + +export const buildAppCallMethodCall = async (params: ProcessedAppCallMethodCall, header: TransactionHeader): Promise => { + const accountReferences = params.accountReferences?.map((a) => a.toString()) + const common = buildMethodCallCommon( + { + appId: 0n, + method: params.method, + args: params.args ?? [], + accountReferences: accountReferences, + appReferences: params.appReferences, + assetReferences: params.assetReferences, + }, + header, + ) + + // If accessReferences is provided, we should not pass legacy foreign arrays + const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 + + return { + ...header, + type: TransactionType.AppCall, + appCall: { + appId: params.appId, + onComplete: params.onComplete ?? OnApplicationComplete.NoOp, + args: common.args, + ...(hasAccessReferences + ? { accessReferences: params.accessReferences } + : { + accountReferences: params.accountReferences?.map((a) => a.toString()), + appReferences: params.appReferences, + assetReferences: params.assetReferences, + boxReferences: params.boxReferences?.map(AppManager.getBoxReference), + }), + rejectVersion: params.rejectVersion, + }, + } satisfies Transaction +} diff --git a/src/transactions/payment.ts b/src/transactions/payment.ts new file mode 100644 index 00000000..fefed837 --- /dev/null +++ b/src/transactions/payment.ts @@ -0,0 +1,28 @@ +import { Transaction, TransactionType } from '@algorandfoundation/algokit-transact' +import { Address } from '@algorandfoundation/sdk' +import { AlgoAmount } from '../types/amount' +import { CommonTransactionParams, TransactionHeader } from './common' + +/** Parameters to define a payment transaction. */ +export type PaymentParams = CommonTransactionParams & { + /** The address of the account that will receive the Algo */ + receiver: string | Address + /** Amount to send */ + amount: AlgoAmount + /** If given, close the sender account and send the remaining balance to this address + * + * *Warning:* Be careful with this parameter as it can lead to loss of funds if not used correctly. + */ + closeRemainderTo?: string | Address +} + +export const buildPayment = (params: PaymentParams, header: TransactionHeader): Transaction => { + return { + ...header, + type: TransactionType.Payment, + payment: { + receiver: params.receiver.toString(), + amount: params.amount.microAlgos, + }, + } +} diff --git a/src/types/__snapshots__/app-client.spec.ts.snap b/src/types/__snapshots__/app-client.spec.ts.snap index bb756790..68a0b3c2 100644 --- a/src/types/__snapshots__/app-client.spec.ts.snap +++ b/src/types/__snapshots__/app-client.spec.ts.snap @@ -5,5 +5,5 @@ exports[`application-client > Errors > Display nice error messages when there is INFO: App TestingApp not found in apps created by ACCOUNT_1; deploying app with version 1.0. VERBOSE: Sent transaction ID TXID_1 appl from ACCOUNT_1 DEBUG: App created by ACCOUNT_1 with ID APP_1 via transaction TXID_1 -ERROR: Received error executing Atomic Transaction Composer and debug flag enabled; attempting simulation to get more information | [{"cause":{},"name":"Error","traces":[]}]" +ERROR: Received error executing Transaction Composer and debug flag enabled; attempting simulation to get more information | [{"name":"BuildComposerTransactionsError","traces":[]}]" `; diff --git a/src/types/__snapshots__/app-factory-and-client.spec.ts.snap b/src/types/__snapshots__/app-factory-and-client.spec.ts.snap index 1ba873f2..9cf2aaf4 100644 --- a/src/types/__snapshots__/app-factory-and-client.spec.ts.snap +++ b/src/types/__snapshots__/app-factory-and-client.spec.ts.snap @@ -5,7 +5,7 @@ exports[`ARC32: app-factory-and-app-client > Errors > Display nice error message INFO: App TestingApp not found in apps created by ACCOUNT_1; deploying app with version 1.0. VERBOSE: Sent transaction ID TXID_1 appl from ACCOUNT_1 DEBUG: App created by ACCOUNT_1 with ID APP_1 via transaction TXID_1 -ERROR: Received error executing Atomic Transaction Composer and debug flag enabled; attempting simulation to get more information | [{"cause":{},"name":"Error","traces":[]}] +ERROR: Received error executing Transaction Composer and debug flag enabled; attempting simulation to get more information | [{"name":"BuildComposerTransactionsError","traces":[]}] ERROR: assert failed pc=885. at:469. Error resolving execution info via simulate in transaction 0: transaction TXID_2: logic eval error: assert failed pc=885. Details: app=APP_1, pc=885, opcodes=proto 0 0; intc_0 // 0; assert 464: // error diff --git a/src/types/algorand-client-transaction-creator.ts b/src/types/algorand-client-transaction-creator.ts index f5708048..03a95f65 100644 --- a/src/types/algorand-client-transaction-creator.ts +++ b/src/types/algorand-client-transaction-creator.ts @@ -1,4 +1,5 @@ -import { BuiltTransactions, TransactionComposer } from './composer' +import { TransactionSigner } from '@algorandfoundation/sdk' +import { BuiltTransactions, TransactionComposer, TransactionComposerConfig } from './composer' import { Expand } from './expand' import { TransactionWrapper } from './transaction' @@ -14,15 +15,15 @@ export class AlgorandClientTransactionCreator { * const transactionCreator = new AlgorandClientTransactionCreator(() => new TransactionComposer()) * ``` */ - constructor(newGroup: () => TransactionComposer) { + constructor(newGroup: (config?: TransactionComposerConfig) => TransactionComposer) { this._newGroup = newGroup } private _transaction(c: (c: TransactionComposer) => (params: T) => TransactionComposer): (params: T) => Promise { return async (params: T) => { const composer = this._newGroup() - const result = await c(composer).apply(composer, [params]).buildTransactions() - return new TransactionWrapper(result.transactions.at(-1)!) + const result = await c(composer).apply(composer, [params]).build() + return new TransactionWrapper(result.transactions.at(-1)!.txn) } } @@ -31,7 +32,23 @@ export class AlgorandClientTransactionCreator { ): (params: T) => Promise> { return async (params: T) => { const composer = this._newGroup() - return await c(composer).apply(composer, [params]).buildTransactions() + const buildResult = await c(composer).apply(composer, [params]).build() + + const transactions = buildResult.transactions.map((txnWithSigner) => txnWithSigner.txn) + transactions.forEach((txn) => { + delete txn.group + }) + + const signers = new Map() + buildResult.transactions.forEach((txnWithSigner, index) => { + signers.set(index, txnWithSigner.signer) + }) + + return { + transactions, + methodCalls: buildResult.methodCalls, + signers, + } } } diff --git a/src/types/algorand-client-transaction-sender.ts b/src/types/algorand-client-transaction-sender.ts index e3f16e3c..66540099 100644 --- a/src/types/algorand-client-transaction-sender.ts +++ b/src/types/algorand-client-transaction-sender.ts @@ -19,6 +19,7 @@ import { AssetCreateParams, AssetOptOutParams, TransactionComposer, + TransactionComposerConfig, } from './composer' import { SendParams, SendSingleTransactionResult } from './transaction' @@ -49,7 +50,7 @@ export class AlgorandClientTransactionSender { * const transactionSender = new AlgorandClientTransactionSender(() => new TransactionComposer(), assetManager, appManager) * ``` */ - constructor(newGroup: () => TransactionComposer, assetManager: AssetManager, appManager: AppManager) { + constructor(newGroup: (config?: TransactionComposerConfig) => TransactionComposer, assetManager: AssetManager, appManager: AppManager) { this._newGroup = newGroup this._assetManager = assetManager this._appManager = appManager diff --git a/src/types/algorand-client.spec.ts b/src/types/algorand-client.spec.ts index 40f8b159..ab345370 100644 --- a/src/types/algorand-client.spec.ts +++ b/src/types/algorand-client.spec.ts @@ -61,15 +61,16 @@ describe('AlgorandClient', () => { expect(createResult.assetId).toBeGreaterThan(0) }) - test('addAtc from generated client', async () => { + test('addTransactionComposer from generated client', async () => { const alicePreBalance = (await algorand.account.getInformation(alice)).balance const bobPreBalance = (await algorand.account.getInformation(bob)).balance - const doMathAtc = await appClient.compose().doMath({ a: 1, b: 2, operation: 'sum' }).atc() + const doMathComposer = await appClient.compose().doMath({ a: 1, b: 2, operation: 'sum' }).transactionComposer() + const result = await algorand .newGroup() .addPayment({ sender: alice, receiver: bob, amount: AlgoAmount.MicroAlgo(1) }) - .addAtc(doMathAtc) + .addTransactionComposer(doMathComposer) .send() const alicePostBalance = (await algorand.account.getInformation(alice)).balance diff --git a/src/types/algorand-client.ts b/src/types/algorand-client.ts index 1d42e97d..9ea0a423 100644 --- a/src/types/algorand-client.ts +++ b/src/types/algorand-client.ts @@ -10,7 +10,7 @@ import { AppDeployer } from './app-deployer' import { AppManager } from './app-manager' import { AssetManager } from './asset-manager' import { AlgoSdkClients, ClientManager } from './client-manager' -import { ErrorTransformer, TransactionComposer } from './composer' +import { ErrorTransformer, TransactionComposer, TransactionComposerConfig } from './composer' import { AlgoConfig } from './network-client' /** @@ -42,9 +42,9 @@ export class AlgorandClient { this._clientManager = new ClientManager(config, this) this._accountManager = new AccountManager(this._clientManager) this._appManager = new AppManager(this._clientManager.algod) - this._assetManager = new AssetManager(this._clientManager.algod, () => this.newGroup()) - this._transactionSender = new AlgorandClientTransactionSender(() => this.newGroup(), this._assetManager, this._appManager) - this._transactionCreator = new AlgorandClientTransactionCreator(() => this.newGroup()) + this._assetManager = new AssetManager(this._clientManager.algod, (config) => this.newGroup(config)) + this._transactionSender = new AlgorandClientTransactionSender((config) => this.newGroup(config), this._assetManager, this._appManager) + this._transactionCreator = new AlgorandClientTransactionCreator((config) => this.newGroup(config)) this._appDeployer = new AppDeployer(this._appManager, this._transactionSender, this._clientManager.indexerIfPresent) } @@ -232,7 +232,7 @@ export class AlgorandClient { * const composer = AlgorandClient.mainNet().newGroup(); * const result = await composer.addTransaction(payment).send() */ - public newGroup() { + public newGroup(composerConfig?: TransactionComposerConfig) { return new TransactionComposer({ algod: this.client.algod, getSigner: (addr: string | Address) => this.account.getSigner(addr), @@ -240,6 +240,7 @@ export class AlgorandClient { defaultValidityWindow: this._defaultValidityWindow, appManager: this._appManager, errorTransformers: [...this._errorTransformers], + composerConfig: composerConfig, }) } diff --git a/src/types/app-client.spec.ts b/src/types/app-client.spec.ts index 7ba95292..202aefc6 100644 --- a/src/types/app-client.spec.ts +++ b/src/types/app-client.spec.ts @@ -890,7 +890,7 @@ describe('app-client', () => { test('clone overriding the defaultSender and inheriting appName', async () => { const { testAccount } = localnet.context const appClient = await deploy(testAccount, 'overridden') - const testAccount2 = await localnet.context.generateAccount({ initialFunds: algo(0.1) }) + const testAccount2 = await localnet.context.generateAccount({ initialFunds: algo(2) }) const clonedAppClient = appClient.clone({ defaultSender: testAccount2.addr, @@ -899,7 +899,9 @@ describe('app-client', () => { expect(appClient.appName).toBe('overridden') expect(clonedAppClient.appId).toBe(appClient.appId) expect(clonedAppClient.appName).toBe(appClient.appName) - expect((await clonedAppClient.createTransaction.bare.call()).sender).toBe(testAccount2.addr.toString()) + expect((await clonedAppClient.createTransaction.call({ method: 'default_value', args: ['test value'] })).transactions[0].sender).toBe( + testAccount2.addr.toString(), + ) }) test('clone overriding appName', async () => { diff --git a/src/types/app-client.ts b/src/types/app-client.ts index cb79d9a3..c67c771e 100644 --- a/src/types/app-client.ts +++ b/src/types/app-client.ts @@ -7,7 +7,6 @@ import { ABIType, ABIValue, Address, - AtomicTransactionComposer, Indexer, ProgramSourceMap, TransactionSigner, @@ -36,7 +35,6 @@ import { AlgoAmount } from './amount' import { ABIAppCallArg, ABIAppCallArgs, - ABIReturn, AppCallArgs, AppCallTransactionResult, AppCallType, @@ -83,6 +81,7 @@ import { AppUpdateParams, CommonAppCallParams, PaymentParams, + TransactionComposer, } from './composer' import { Expand } from './expand' import { EventType } from './lifecycle-events' @@ -94,7 +93,6 @@ import { TransactionNote, TransactionWrapper, wrapPendingTransactionResponse, - wrapPendingTransactionResponseOptional, } from './transaction' /** The maximum opcode budget for a simulate call as per https://github.com/algorand/go-algorand/blob/807b29a91c371d225e12b9287c5d56e9b33c4e4c/ledger/simulation/trace.go#L104 */ @@ -2162,24 +2160,31 @@ export class ApplicationClient { call?.method && // We aren't skipping the send !call.sendParams?.skipSending && - // There isn't an ATC passed in - !call.sendParams?.atc && + // There isn't a composer passed in + !call.sendParams?.transactionComposer && // The method is readonly - this.appSpec.hints[this.getABIMethodSignature(this.getABIMethod(call.method)!)].read_only + this.appSpec.hints?.[this.getABIMethodSignature(this.getABIMethod(call.method)!)]?.read_only ) { - const atc = new AtomicTransactionComposer() - await this.callOfType({ ...call, sendParams: { ...call.sendParams, atc } }, 'no_op') - const result = await atc.simulate(this.algod) + const transactionComposer = new TransactionComposer({ + algod: this.algod, + getSigner: (address) => { + throw new Error(`No signer for address ${address}`) + }, + }) + await this.callOfType({ ...call, sendParams: { ...call.sendParams, transactionComposer } }, 'no_op') + const result = await transactionComposer.simulate() if (result.simulateResponse.txnGroups.some((group) => group.failureMessage)) { throw new Error(result.simulateResponse.txnGroups.find((x) => x.failureMessage)?.failureMessage) } - const txns = atc.buildGroup() + const confirmations = result.simulateResponse.txnGroups[0].txnResults.map((t) => wrapPendingTransactionResponse(t.txnResult)) + const abiReturn = result.returns?.at(-1) + return { - transaction: new TransactionWrapper(txns[txns.length - 1].txn), - confirmation: wrapPendingTransactionResponseOptional(result.simulateResponse.txnGroups[0].txnResults.at(-1)?.txnResult), - confirmations: result.simulateResponse.txnGroups[0].txnResults.map((t) => wrapPendingTransactionResponse(t.txnResult)), - transactions: txns.map((t) => new TransactionWrapper(t.txn)), - return: (result.methodResults?.length ?? 0 > 0) ? (result.methodResults[result.methodResults.length - 1] as ABIReturn) : undefined, + transaction: result.transactions.at(-1)!, + confirmation: confirmations.at(-1)!, + confirmations: confirmations, + transactions: result.transactions, + return: abiReturn, } satisfies AppCallTransactionResult } diff --git a/src/types/app-deployer.ts b/src/types/app-deployer.ts index f177805b..d6c55e94 100644 --- a/src/types/app-deployer.ts +++ b/src/types/app-deployer.ts @@ -366,7 +366,7 @@ export class AppDeployer { const existingAppRecord = await this._appManager.getById(existingApp.appId) const existingApproval = Buffer.from(existingAppRecord.approvalProgram).toString('base64') const existingClear = Buffer.from(existingAppRecord.clearStateProgram).toString('base64') - const existingExtraPages = calculateExtraProgramPages(existingAppRecord.approvalProgram, existingAppRecord.clearStateProgram) + const extraPages = existingAppRecord.extraProgramPages ?? 0 const newApprovalBytes = Buffer.from(approvalProgram) const newClearBytes = Buffer.from(clearStateProgram) @@ -382,7 +382,7 @@ export class AppDeployer { existingAppRecord.globalInts < (createParams.schema?.globalInts ?? 0) || existingAppRecord.localByteSlices < (createParams.schema?.localByteSlices ?? 0) || existingAppRecord.globalByteSlices < (createParams.schema?.globalByteSlices ?? 0) || - existingExtraPages < newExtraPages + extraPages < newExtraPages if (isSchemaBreak) { Config.getLogger(sendParams?.suppressLog).warn(`Detected a breaking app schema change in app ${existingApp.appId}:`, { @@ -391,7 +391,7 @@ export class AppDeployer { globalByteSlices: existingAppRecord.globalByteSlices, localInts: existingAppRecord.localInts, localByteSlices: existingAppRecord.localByteSlices, - extraProgramPages: existingExtraPages, + extraProgramPages: extraPages, }, to: { ...createParams.schema, extraProgramPages: newExtraPages }, }) diff --git a/src/types/app-factory-and-client.spec.ts b/src/types/app-factory-and-client.spec.ts index ada34808..b9772610 100644 --- a/src/types/app-factory-and-client.spec.ts +++ b/src/types/app-factory-and-client.spec.ts @@ -194,7 +194,7 @@ describe('ARC32: app-factory-and-app-client', () => { expect(app.return).toBe('arg_io') }) - test('Deploy app - update detects extra pages as breaking change', async () => { + test('Deploy app - update detects extra page deficit as a breaking change', async () => { let appFactory = localnet.algorand.client.getAppFactory({ appSpec: asJson(smallAppArc56Json), defaultSender: localnet.context.testAccount, @@ -234,6 +234,37 @@ describe('ARC32: app-factory-and-app-client', () => { expect(appCreateResult.appId).not.toEqual(appAppendResult.appId) }) + test('Deploy app - update detects extra page surplus as a non breaking change', async () => { + let appFactory = localnet.algorand.client.getAppFactory({ + appSpec: asJson(smallAppArc56Json), + defaultSender: localnet.context.testAccount, + }) + + const { result: appCreateResult } = await appFactory.deploy({ + updatable: true, + createParams: { + extraProgramPages: 1, + }, + }) + + expect(appCreateResult.operationPerformed).toBe('create') + + // Update the app to a larger program which needs more pages than the previous program + appFactory = localnet.algorand.client.getAppFactory({ + appSpec: asJson(largeAppArc56Json), + defaultSender: localnet.context.testAccount, + }) + + const { result: appUpdateResult } = await appFactory.deploy({ + updatable: true, + onSchemaBreak: OnSchemaBreak.Fail, + onUpdate: OnUpdate.UpdateApp, + }) + + expect(appUpdateResult.operationPerformed).toBe('update') + expect(appCreateResult.appId).toEqual(appUpdateResult.appId) + }) + test('Deploy app - replace', async () => { const { result: createdApp } = await factory.deploy({ deployTimeParams: { diff --git a/src/types/app-manager.ts b/src/types/app-manager.ts index c1e831d2..30dccdde 100644 --- a/src/types/app-manager.ts +++ b/src/types/app-manager.ts @@ -5,6 +5,7 @@ import { Address, ProgramSourceMap } from '@algorandfoundation/sdk' import { getABIReturnValue } from '../transaction/transaction' import { TransactionSignerAccount } from './account' import { + ABI_RETURN_PREFIX, BoxName, DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, @@ -437,12 +438,42 @@ export class AppManager { } // The parseMethodResponse method mutates the second parameter :( - const resultDummy: algosdk.ABIResult = { + const abiResult: algosdk.ABIResult = { txID: '', method, rawReturnValue: new Uint8Array(), } - return getABIReturnValue(algosdk.AtomicTransactionComposer.parseMethodResponse(method, resultDummy, confirmation), method.returns.type) + + try { + abiResult.txInfo = confirmation + const logs = confirmation.logs || [] + if (logs.length === 0) { + throw new Error(`App call transaction did not log a return value`) + } + const lastLog = logs[logs.length - 1] + if (!AppManager.hasAbiReturnPrefix(lastLog)) { + throw new Error(`App call transaction did not log an ABI return value`) + } + + abiResult.rawReturnValue = new Uint8Array(lastLog.slice(4)) + abiResult.returnValue = method.returns.type.decode(abiResult.rawReturnValue) + } catch (err) { + abiResult.decodeError = err as Error + } + + return getABIReturnValue(abiResult, method.returns.type) + } + + private static hasAbiReturnPrefix(log: Uint8Array): boolean { + if (log.length < ABI_RETURN_PREFIX.length) { + return false + } + for (let i = 0; i < ABI_RETURN_PREFIX.length; i++) { + if (log[i] !== ABI_RETURN_PREFIX[i]) { + return false + } + } + return true } /** diff --git a/src/types/app.ts b/src/types/app.ts index fea73d46..c8fa4526 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,6 +1,7 @@ import { SuggestedParams } from '@algorandfoundation/algokit-algod-client' import { OnApplicationComplete, BoxReference as TransactBoxReference, Transaction } from '@algorandfoundation/algokit-transact' -import { ABIArgument, ABIMethod, ABIMethodParams, ABIType, ABIValue, Address, ProgramSourceMap } from '@algorandfoundation/sdk' +import { ABIMethod, ABIMethodParams, ABIType, ABIValue, Address, ProgramSourceMap } from '@algorandfoundation/sdk' +import { TransactionWithSigner } from '../transaction' import { Expand } from './expand' import { SendSingleTransactionResult, @@ -93,7 +94,8 @@ export interface RawAppCallArgs extends CoreAppCallArgs { /** An argument for an ABI method, either a primitive value, or a transaction with or without signer, or the unawaited async return value of an algokit method that returns a `SendTransactionResult` */ export type ABIAppCallArg = - | ABIArgument + | ABIValue + | TransactionWithSigner | TransactionToSign | Transaction | Promise diff --git a/src/types/asset-manager.ts b/src/types/asset-manager.ts index 5146fc32..f83c2421 100644 --- a/src/types/asset-manager.ts +++ b/src/types/asset-manager.ts @@ -3,7 +3,7 @@ import { Address } from '@algorandfoundation/sdk' import { Config } from '../config' import { chunkArray } from '../util' import { AccountAssetInformation } from './account' -import { CommonTransactionParams, MAX_TRANSACTION_GROUP_SIZE, TransactionComposer } from './composer' +import { CommonTransactionParams, MAX_TRANSACTION_GROUP_SIZE, TransactionComposer, TransactionComposerConfig } from './composer' import { SendParams } from './transaction' /** Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets. */ @@ -149,7 +149,7 @@ export class AssetManager { * const assetManager = new AssetManager(algod, () => new TransactionComposer({algod, () => signer, () => suggestedParams})) * ``` */ - constructor(algod: AlgodClient, newGroup: () => TransactionComposer) { + constructor(algod: AlgodClient, newGroup: (config?: TransactionComposerConfig) => TransactionComposer) { this._algod = algod this._newGroup = newGroup } diff --git a/src/types/composer.spec.ts b/src/types/composer.spec.ts index f09410fe..9e0b0403 100644 --- a/src/types/composer.spec.ts +++ b/src/types/composer.spec.ts @@ -1,5 +1,7 @@ +import { ABIMethod } from '@algorandfoundation/sdk' import { beforeEach, describe, expect, test } from 'vitest' import { algorandFixture } from '../testing' +import { AlgoAmount } from './amount' describe('TransactionComposer', () => { const fixture = algorandFixture() @@ -63,5 +65,97 @@ describe('TransactionComposer', () => { await expect(composer.send()).rejects.toThrow('ASSET MISSING!') }) + + test('not throw error from simulate when the flag is set', async () => { + const algorand = fixture.context.algorand + const sender = fixture.context.testAccount + const composer = algorand.newGroup() + + composer.addAssetTransfer({ + amount: 1n, + assetId: 1337n, + sender, + receiver: sender, + }) + + errorTransformers.forEach((errorTransformer) => { + composer.registerErrorTransformer(errorTransformer) + }) + + const simulateResult = await composer.simulate({ throwOnFailure: false }) + expect(simulateResult).toBeDefined() + }) + }) + + describe('clone composers', () => { + test('async transaction argument can be cloned correctly', async () => { + const { algorand, context } = fixture + + const testAccount = context.testAccount + + const composer1 = context.algorand.newGroup({ populateAppCallResources: false, coverAppCallInnerTransactionFees: false }) + composer1.addAppCallMethodCall({ + appId: 123n, + sender: testAccount, + method: ABIMethod.fromSignature('createBoxInNewApp(pay)void'), + args: [ + algorand.createTransaction.payment({ + sender: testAccount, + receiver: testAccount, + amount: AlgoAmount.Algos(1), + }), + ], + }) + + const composer2 = composer1.clone() + composer2.addPayment({ + sender: testAccount, + receiver: testAccount, + amount: AlgoAmount.Algos(2), + }) + + const composer2Transactions = (await composer2.build()).transactions + expect(composer2Transactions[0].txn.group).toBeDefined() + + const composer1Transactions = (await composer1.build()).transactions + expect(composer1Transactions[0].txn.group).toBeDefined() + + expect(composer2Transactions[0].txn.group).not.toEqual(composer1Transactions[0].txn.group) + }) + + test('transaction argument can be cloned correctly', async () => { + const { algorand, context } = fixture + + const testAccount = context.testAccount + const paymentTxn = await algorand.createTransaction.payment({ + sender: testAccount, + receiver: testAccount, + amount: AlgoAmount.Algos(1), + }) + + const composer1 = context.algorand.newGroup({ populateAppCallResources: false, coverAppCallInnerTransactionFees: false }) + composer1.addAppCallMethodCall({ + appId: 123n, + sender: testAccount, + method: ABIMethod.fromSignature('createBoxInNewApp(pay)void'), + args: [paymentTxn], + }) + + const composer2 = composer1.clone() + composer2.addPayment({ + sender: testAccount, + receiver: testAccount, + amount: AlgoAmount.Algos(2), + }) + + const composer2Transactions = (await composer2.build()).transactions + expect(composer2Transactions[0].txn.group).toBeDefined() + + const composer1Transactions = (await composer1.build()).transactions + expect(composer1Transactions[0].txn.group).toBeDefined() + + expect(composer2Transactions[0].txn.group).not.toEqual(composer1Transactions[0].txn.group) + expect(paymentTxn.group).toEqual(composer1Transactions[0].txn.group) + }) }) }) diff --git a/src/types/composer.ts b/src/types/composer.ts index eeb518a1..c4121580 100644 --- a/src/types/composer.ts +++ b/src/types/composer.ts @@ -1,33 +1,126 @@ -import { AlgodClient, SimulateRequest, SimulateTransaction, SuggestedParams } from '@algorandfoundation/algokit-algod-client' -import { AccessReference, OnApplicationComplete, Transaction, assignFee, getTransactionId } from '@algorandfoundation/algokit-transact' -import * as algosdk from '@algorandfoundation/sdk' import { - ABIMethod, - Address, - AtomicTransactionComposer, - TransactionSigner, - TransactionWithSigner, - isTransactionWithSigner, -} from '@algorandfoundation/sdk' + AlgodClient, + AlgorandSerializer, + PendingTransactionResponse, + SimulateRequest, + SimulateTransaction, + SimulateUnnamedResourcesAccessed, + SimulationTransactionExecTraceMeta, + SuggestedParams, + toBase64, +} from '@algorandfoundation/algokit-algod-client' +import { EMPTY_SIGNATURE } from '@algorandfoundation/algokit-common' +import { + OnApplicationComplete, + SignedTransaction, + Transaction, + TransactionType, + assignFee, + calculateFee, + decodeSignedTransaction, + decodeTransaction, + encodeSignedTransactions, + encodeTransactionRaw, + getTransactionId, + groupTransactions, +} from '@algorandfoundation/algokit-transact' +import * as algosdk from '@algorandfoundation/sdk' +import { ABIMethod, Address, TransactionSigner } from '@algorandfoundation/sdk' import { Config } from '../config' -import { encodeLease, getABIReturnValue, sendAtomicTransactionComposer } from '../transaction/transaction' -import { asJson, calculateExtraProgramPages } from '../util' -import { TransactionSignerAccount } from './account' +import { TransactionWithSigner, waitForConfirmation } from '../transaction' +import { + buildAppCall, + buildAppCreate, + buildAppUpdate, + populateGroupResources, + populateTransactionResources, + type AppCallParams, + type AppCreateParams, + type AppDeleteParams, + type AppUpdateParams, +} from '../transactions/app-call' +import { + buildAssetOptIn, + buildAssetOptOut, + buildAssetTransfer, + type AssetOptInParams, + type AssetOptOutParams, + type AssetTransferParams, +} from '../transactions/asset-transfer' + +import { + buildAssetConfig, + buildAssetCreate, + buildAssetDestroy, + buildAssetFreeze, + type AssetConfigParams, + type AssetCreateParams, + type AssetDestroyParams, + type AssetFreezeParams, +} from '../transactions/asset-config' +import { buildTransactionHeader, calculateInnerFeeDelta } from '../transactions/common' +import { FeeDelta, FeePriority } from '../transactions/fee-coverage' +import { buildKeyReg, type OfflineKeyRegistrationParams, type OnlineKeyRegistrationParams } from '../transactions/key-registration' +import { + AsyncTransactionParams, + TransactionParams, + buildAppCallMethodCall, + buildAppCreateMethodCall, + buildAppUpdateMethodCall, + extractComposerTransactionsFromAppMethodCallParams, + processAppMethodCallArgs, + type AppCallMethodCall, + type AppCreateMethodCall, + type AppDeleteMethodCall, + type AppUpdateMethodCall, + type ProcessedAppCallMethodCall, + type ProcessedAppCreateMethodCall, + type ProcessedAppUpdateMethodCall, +} from '../transactions/method-call' +import { buildPayment, type PaymentParams } from '../transactions/payment' +import { asJson } from '../util' import { AlgoAmount } from './amount' -import { AppManager, BoxIdentifier, BoxReference } from './app-manager' +import { ABIReturn } from './app' +import { AppManager } from './app-manager' import { Expand } from './expand' import { EventType } from './lifecycle-events' import { genesisIdIsLocalNet } from './network-client' import { Arc2TransactionNote, - SendAtomicTransactionComposerResults, SendParams, + SendTransactionComposerResults, TransactionWrapper, wrapPendingTransactionResponse, } from './transaction' export const MAX_TRANSACTION_GROUP_SIZE = 16 +// Re-export transaction parameter types +export type { + AppCallParams, + AppCreateParams, + AppDeleteParams, + AppMethodCallParams, + AppUpdateParams, + CommonAppCallParams, +} from '../transactions/app-call' +export type { AssetConfigParams, AssetCreateParams, AssetDestroyParams, AssetFreezeParams } from '../transactions/asset-config' +export type { AssetOptInParams, AssetOptOutParams, AssetTransferParams } from '../transactions/asset-transfer' +export type { CommonTransactionParams } from '../transactions/common' +export type { OfflineKeyRegistrationParams, OnlineKeyRegistrationParams } from '../transactions/key-registration' +export type { + AppCallMethodCall, + AppCreateMethodCall, + AppDeleteMethodCall, + AppMethodCall, + AppMethodCallTransactionArgument, + AppUpdateMethodCall, + ProcessedAppCallMethodCall, + ProcessedAppCreateMethodCall, + ProcessedAppUpdateMethodCall, +} from '../transactions/method-call' +export type { PaymentParams } from '../transactions/payment' + /** Options to control a simulate request, that does not require transaction signing */ export type SkipSignaturesSimulateOptions = Expand< Omit & { @@ -41,443 +134,28 @@ export type SkipSignaturesSimulateOptions = Expand< /** The raw API options to control a simulate request. * See algod API docs for more information: https://dev.algorand.co/reference/rest-apis/algod/#simulatetransaction */ -export type RawSimulateOptions = Expand> +export type RawSimulateOptions = Expand> & { + /** Whether or not to throw error on simulation failure */ + throwOnFailure?: boolean +} /** All options to control a simulate request */ export type SimulateOptions = Expand & RawSimulateOptions> -/** Common parameters for defining a transaction. */ -export type CommonTransactionParams = { - /** The address of the account sending the transaction. */ - sender: string | Address - /** The function used to sign transaction(s); if not specified then - * an attempt will be made to find a registered signer for the - * given `sender` or use a default signer (if configured). - */ - signer?: algosdk.TransactionSigner | TransactionSignerAccount - /** Change the signing key of the sender to the given address. - * - * **Warning:** Please be careful with this parameter and be sure to read the [official rekey guidance](https://dev.algorand.co/concepts/accounts/rekeying). - */ - rekeyTo?: string | Address - /** Note to attach to the transaction. Max of 1000 bytes. */ - note?: Uint8Array | string - /** Prevent multiple transactions with the same lease being included within the validity window. - * - * A [lease](https://dev.algorand.co/concepts/transactions/leases) - * enforces a mutually exclusive transaction (useful to prevent double-posting and other scenarios). - */ - lease?: Uint8Array | string - /** The static transaction fee. In most cases you want to use `extraFee` unless setting the fee to 0 to be covered by another transaction. */ - staticFee?: AlgoAmount - /** The fee to pay IN ADDITION to the suggested fee. Useful for manually covering inner transaction fees. */ - extraFee?: AlgoAmount - /** Throw an error if the fee for the transaction is more than this amount; prevents overspending on fees during high congestion periods. */ - maxFee?: AlgoAmount - /** How many rounds the transaction should be valid for, if not specified then the registered default validity window will be used. */ - validityWindow?: number | bigint - /** - * Set the first round this transaction is valid. - * If left undefined, the value from algod will be used. - * - * We recommend you only set this when you intentionally want this to be some time in the future. - */ - firstValidRound?: bigint - /** The last round this transaction is valid. It is recommended to use `validityWindow` instead. */ - lastValidRound?: bigint -} - -/** Parameters to define a payment transaction. */ -export type PaymentParams = CommonTransactionParams & { - /** The address of the account that will receive the Algo */ - receiver: string | Address - /** Amount to send */ - amount: AlgoAmount - /** If given, close the sender account and send the remaining balance to this address - * - * *Warning:* Be careful with this parameter as it can lead to loss of funds if not used correctly. - */ - closeRemainderTo?: string | Address -} - -/** Parameters to define an asset create transaction. - * - * The account that sends this transaction will automatically be opted in to the asset and will hold all units after creation. - */ -export type AssetCreateParams = CommonTransactionParams & { - /** The total amount of the smallest divisible (decimal) unit to create. - * - * For example, if `decimals` is, say, 2, then for every 100 `total` there would be 1 whole unit. - * - * This field can only be specified upon asset creation. - */ - total: bigint - - /** The amount of decimal places the asset should have. - * - * If unspecified then the asset will be in whole units (i.e. `0`). - * - * * If 0, the asset is not divisible; - * * If 1, the base unit of the asset is in tenths; - * * If 2, the base unit of the asset is in hundredths; - * * If 3, the base unit of the asset is in thousandths; - * * and so on up to 19 decimal places. - * - * This field can only be specified upon asset creation. - */ - decimals?: number - - /** The optional name of the asset. - * - * Max size is 32 bytes. - * - * This field can only be specified upon asset creation. - */ - assetName?: string - - /** The optional name of the unit of this asset (e.g. ticker name). - * - * Max size is 8 bytes. - * - * This field can only be specified upon asset creation. - */ - unitName?: string - - /** Specifies an optional URL where more information about the asset can be retrieved (e.g. metadata). - * - * Max size is 96 bytes. - * - * This field can only be specified upon asset creation. - */ - url?: string - - /** 32-byte hash of some metadata that is relevant to your asset and/or asset holders. - * - * The format of this metadata is up to the application. - * - * This field can only be specified upon asset creation. - */ - metadataHash?: string | Uint8Array - - /** Whether the asset is frozen by default for all accounts. - * Defaults to `false`. - * - * If `true` then for anyone apart from the creator to hold the - * asset it needs to be unfrozen per account using an asset freeze - * transaction from the `freeze` account, which must be set on creation. - * - * This field can only be specified upon asset creation. - */ - defaultFrozen?: boolean - - /** The address of the optional account that can manage the configuration of the asset and destroy it. - * - * The configuration fields it can change are `manager`, `reserve`, `clawback`, and `freeze`. - * - * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the `manager` the asset becomes permanently immutable. - */ - manager?: string | Address - - /** - * The address of the optional account that holds the reserve (uncirculated supply) units of the asset. - * - * This address has no specific authority in the protocol itself and is informational only. - * - * Some standards like [ARC-19](https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0019.md) - * rely on this field to hold meaningful data. - * - * It can be used in the case where you want to signal to holders of your asset that the uncirculated units - * of the asset reside in an account that is different from the default creator account. - * - * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the manager the field is permanently empty. - */ - reserve?: string | Address - - /** - * The address of the optional account that can be used to freeze or unfreeze holdings of this asset for any account. - * - * If empty, freezing is not permitted. - * - * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the manager the field is permanently empty. - */ - freeze?: string | Address - - /** - * The address of the optional account that can clawback holdings of this asset from any account. - * - * **This field should be used with caution** as the clawback account has the ability to **unconditionally take assets from any account**. - * - * If empty, clawback is not permitted. - * - * If not set (`undefined` or `""`) at asset creation or subsequently set to empty by the manager the field is permanently empty. - */ - clawback?: string | Address -} - -/** Parameters to define an asset reconfiguration transaction. - * - * **Note:** The manager, reserve, freeze, and clawback addresses - * are immutably empty if they are not set. If manager is not set then - * all fields are immutable from that point forward. - */ -export type AssetConfigParams = CommonTransactionParams & { - /** ID of the asset to reconfigure */ - assetId: bigint - /** The address of the optional account that can manage the configuration of the asset and destroy it. - * - * The configuration fields it can change are `manager`, `reserve`, `clawback`, and `freeze`. - * - * If not set (`undefined` or `""`) the asset will become permanently immutable. - */ - manager: string | Address | undefined - /** - * The address of the optional account that holds the reserve (uncirculated supply) units of the asset. - * - * This address has no specific authority in the protocol itself and is informational only. - * - * Some standards like [ARC-19](https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0019.md) - * rely on this field to hold meaningful data. - * - * It can be used in the case where you want to signal to holders of your asset that the uncirculated units - * of the asset reside in an account that is different from the default creator account. - * - * If not set (`undefined` or `""`) the field will become permanently empty. - */ - reserve?: string | Address - /** - * The address of the optional account that can be used to freeze or unfreeze holdings of this asset for any account. - * - * If empty, freezing is not permitted. - * - * If not set (`undefined` or `""`) the field will become permanently empty. - */ - freeze?: string | Address - /** - * The address of the optional account that can clawback holdings of this asset from any account. - * - * **This field should be used with caution** as the clawback account has the ability to **unconditionally take assets from any account**. - * - * If empty, clawback is not permitted. - * - * If not set (`undefined` or `""`) the field will become permanently empty. - */ - clawback?: string | Address -} - -/** Parameters to define an asset freeze transaction. */ -export type AssetFreezeParams = CommonTransactionParams & { - /** The ID of the asset to freeze/unfreeze */ - assetId: bigint - /** The address of the account to freeze or unfreeze */ - account: string | Address - /** Whether the assets in the account should be frozen */ - frozen: boolean -} - -/** Parameters to define an asset destroy transaction. - * - * Created assets can be destroyed only by the asset manager account. All of the assets must be owned by the creator of the asset before the asset can be deleted. - */ -export type AssetDestroyParams = CommonTransactionParams & { - /** ID of the asset to destroy */ - assetId: bigint -} - -/** Parameters to define an asset transfer transaction. */ -export type AssetTransferParams = CommonTransactionParams & { - /** ID of the asset to transfer. */ - assetId: bigint - /** Amount of the asset to transfer (in smallest divisible (decimal) units). */ - amount: bigint - /** The address of the account that will receive the asset unit(s). */ - receiver: string | Address - /** Optional address of an account to clawback the asset from. - * - * Requires the sender to be the clawback account. - * - * **Warning:** Be careful with this parameter as it can lead to unexpected loss of funds if not used correctly. - */ - clawbackTarget?: string | Address - /** Optional address of an account to close the asset position to. - * - * **Warning:** Be careful with this parameter as it can lead to loss of funds if not used correctly. - */ - closeAssetTo?: string | Address -} - -/** Parameters to define an asset opt-in transaction. */ -export type AssetOptInParams = CommonTransactionParams & { - /** ID of the asset that will be opted-in to. */ - assetId: bigint -} - -/** Parameters to define an asset opt-out transaction. */ -export type AssetOptOutParams = CommonTransactionParams & { - /** ID of the asset that will be opted-out of. */ - assetId: bigint - /** - * The address of the asset creator account to close the asset - * position to (any remaining asset units will be sent to this account). - */ - creator: string | Address -} - -/** Parameters to define an online key registration transaction. */ -export type OnlineKeyRegistrationParams = CommonTransactionParams & { - /** The root participation public key */ - voteKey: Uint8Array - /** The VRF public key */ - selectionKey: Uint8Array - /** The first round that the participation key is valid. Not to be confused with the `firstValid` round of the keyreg transaction */ - voteFirst: bigint - /** The last round that the participation key is valid. Not to be confused with the `lastValid` round of the keyreg transaction */ - voteLast: bigint - /** This is the dilution for the 2-level participation key. It determines the interval (number of rounds) for generating new ephemeral keys */ - voteKeyDilution: bigint - /** The 64 byte state proof public key commitment */ - stateProofKey?: Uint8Array -} - -/** Parameters to define an offline key registration transaction. */ -export type OfflineKeyRegistrationParams = CommonTransactionParams & { - /** Prevent this account from ever participating again. The account will also no longer earn rewards */ - preventAccountFromEverParticipatingAgain?: boolean -} - -/** Common parameters for defining an application call transaction. */ -export type CommonAppCallParams = CommonTransactionParams & { - /** ID of the application; 0 if the application is being created. */ - appId: bigint - /** The [on-complete](https://dev.algorand.co/concepts/smart-contracts/avm#oncomplete) action of the call; defaults to no-op. */ - onComplete?: OnApplicationComplete - /** Any [arguments to pass to the smart contract call](/concepts/smart-contracts/languages/teal/#argument-passing). */ - args?: Uint8Array[] - /** Any account addresses to add to the [accounts array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). */ - accountReferences?: (string | Address)[] - /** The ID of any apps to load to the [foreign apps array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). */ - appReferences?: bigint[] - /** The ID of any assets to load to the [foreign assets array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). */ - assetReferences?: bigint[] - /** Any boxes to load to the [boxes array](https://dev.algorand.co/concepts/smart-contracts/resource-usage#what-are-reference-arrays). - * - * Either the name identifier (which will be set against app ID of `0` i.e. - * the current app), or a box identifier with the name identifier and app ID. - */ - boxReferences?: (BoxReference | BoxIdentifier)[] - /** Access references unifies `accountReferences`, `appReferences`, `assetReferences`, and `boxReferences` under a single list. If non-empty, these other reference lists must be empty. If access is empty, those other reference lists may be non-empty. */ - accessReferences?: AccessReference[] -} - -/** Parameters to define an app create transaction */ -export type AppCreateParams = Expand< - Omit & { - onComplete?: Exclude - /** The program to execute for all OnCompletes other than ClearState as raw teal that will be compiled (string) or compiled teal (encoded as a byte array (Uint8Array)). */ - approvalProgram: string | Uint8Array - /** The program to execute for ClearState OnComplete as raw teal that will be compiled (string) or compiled teal (encoded as a byte array (Uint8Array)). */ - clearStateProgram: string | Uint8Array - /** The state schema for the app. This is immutable once the app is created. */ - schema?: { - /** The number of integers saved in global state. */ - globalInts: number - /** The number of byte slices saved in global state. */ - globalByteSlices: number - /** The number of integers saved in local state. */ - localInts: number - /** The number of byte slices saved in local state. */ - localByteSlices: number - } - /** Number of extra pages required for the programs. - * Defaults to the number needed for the programs in this call if not specified. - * This is immutable once the app is created. */ - extraProgramPages?: number - } -> - -/** Parameters to define an app update transaction */ -export type AppUpdateParams = Expand< - CommonAppCallParams & { - onComplete?: OnApplicationComplete.UpdateApplication - /** The program to execute for all OnCompletes other than ClearState as raw teal (string) or compiled teal (base 64 encoded as a byte array (Uint8Array)) */ - approvalProgram: string | Uint8Array - /** The program to execute for ClearState OnComplete as raw teal (string) or compiled teal (base 64 encoded as a byte array (Uint8Array)) */ - clearStateProgram: string | Uint8Array - } -> - -/** Parameters to define an application call transaction. */ -export type AppCallParams = CommonAppCallParams & { - onComplete?: Exclude -} - -/** Common parameters to define an ABI method call transaction. */ -export type AppMethodCallParams = CommonAppCallParams & { - onComplete?: Exclude -} - -/** Parameters to define an application delete call transaction. */ -export type AppDeleteParams = CommonAppCallParams & { - onComplete?: OnApplicationComplete.DeleteApplication -} - -/** Parameters to define an ABI method call create transaction. */ -export type AppCreateMethodCall = AppMethodCall -/** Parameters to define an ABI method call update transaction. */ -export type AppUpdateMethodCall = AppMethodCall -/** Parameters to define an ABI method call delete transaction. */ -export type AppDeleteMethodCall = AppMethodCall -/** Parameters to define an ABI method call transaction. */ -export type AppCallMethodCall = AppMethodCall - -/** Types that can be used to define a transaction argument for an ABI call transaction. */ -export type AppMethodCallTransactionArgument = - // The following should match the partial `args` types from `AppMethodCall` below - | TransactionWithSigner - | Transaction - | Promise - | AppMethodCall - | AppMethodCall - | AppMethodCall - -/** Parameters to define an ABI method call. */ -export type AppMethodCall = Expand> & { - /** The ABI method to call */ - method: algosdk.ABIMethod - /** Arguments to the ABI method, either: - * * An ABI value - * * A transaction with explicit signer - * * A transaction (where the signer will be automatically assigned) - * * An unawaited transaction (e.g. from algorand.createTransaction.{transactionType}()) - * * Another method call (via method call params object) - * * undefined (this represents a placeholder transaction argument that is fulfilled by another method call argument) - */ - args?: ( - | algosdk.ABIValue - // The following should match the above `AppMethodCallTransactionArgument` type above - | TransactionWithSigner - | Transaction - | Promise - | AppMethodCall - | AppMethodCall - | AppMethodCall - | undefined - )[] -} - -export type Txn = - | (PaymentParams & { type: 'pay' }) - | (AssetCreateParams & { type: 'assetCreate' }) - | (AssetConfigParams & { type: 'assetConfig' }) - | (AssetFreezeParams & { type: 'assetFreeze' }) - | (AssetDestroyParams & { type: 'assetDestroy' }) - | (AssetTransferParams & { type: 'assetTransfer' }) - | (AssetOptInParams & { type: 'assetOptIn' }) - | (AssetOptOutParams & { type: 'assetOptOut' }) - | ((AppCallParams | AppCreateParams | AppUpdateParams) & { type: 'appCall' }) - | ((OnlineKeyRegistrationParams | OfflineKeyRegistrationParams) & { type: 'keyReg' }) - | (algosdk.TransactionWithSigner & { type: 'txnWithSigner' }) - | { atc: algosdk.AtomicTransactionComposer; type: 'atc' } - | ((AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall) & { type: 'methodCall' }) +type Txn = + | { data: PaymentParams; type: 'pay' } + | { data: AssetCreateParams; type: 'assetCreate' } + | { data: AssetConfigParams; type: 'assetConfig' } + | { data: AssetFreezeParams; type: 'assetFreeze' } + | { data: AssetDestroyParams; type: 'assetDestroy' } + | { data: AssetTransferParams; type: 'assetTransfer' } + | { data: AssetOptInParams; type: 'assetOptIn' } + | { data: AssetOptOutParams; type: 'assetOptOut' } + | { data: AppCallParams | AppCreateParams | AppUpdateParams; type: 'appCall' } + | { data: OnlineKeyRegistrationParams | OfflineKeyRegistrationParams; type: 'keyReg' } + | { data: TransactionParams; type: 'txn' } + | { data: AsyncTransactionParams; type: 'asyncTxn' } + | { data: ProcessedAppCallMethodCall | ProcessedAppCreateMethodCall | ProcessedAppUpdateMethodCall; type: 'methodCall' } /** * A function that transforms an error into a new error. @@ -499,6 +177,25 @@ class ErrorTransformerError extends Error { } } +export type TransactionComposerConfig = { + coverAppCallInnerTransactionFees: boolean + populateAppCallResources: boolean +} + +type TransactionAnalysis = { + /** The fee difference required for this transaction */ + requiredFeeDelta?: FeeDelta + /** Resources accessed by this transaction but not declared */ + unnamedResourcesAccessed?: SimulateUnnamedResourcesAccessed +} + +type GroupAnalysis = { + /** Analysis of each transaction in the group */ + transactions: TransactionAnalysis[] + /** Resources accessed by the group that qualify for group resource sharing */ + unnamedResourcesAccessed?: SimulateUnnamedResourcesAccessed +} + /** Parameters to create an `TransactionComposer`. */ export type TransactionComposerParams = { /** The algod client to use to get suggestedParams and send the transaction group */ @@ -521,22 +218,9 @@ export type TransactionComposerParams = { * callbacks can later be registered with `registerErrorTransformer` */ errorTransformers?: ErrorTransformer[] + composerConfig?: TransactionComposerConfig } -/** Represents a Transaction with additional context that was used to build that transaction. */ -interface TransactionWithContext { - txn: Transaction - context: { - /* The logical max fee for the transaction, if one was supplied. */ - maxFee?: AlgoAmount - /* The ABI method, if the app call transaction is an ABI method call. */ - abiMethod?: algosdk.ABIMethod - } -} - -/** Represents a TransactionWithSigner with additional context that was used to build that transaction. */ -type TransactionWithSignerAndContext = algosdk.TransactionWithSigner & TransactionWithContext - /** Set of transactions built by `TransactionComposer`. */ export interface BuiltTransactions { /** The built transactions */ @@ -547,19 +231,19 @@ export interface BuiltTransactions { signers: Map } +class BuildComposerTransactionsError extends Error { + constructor( + message: string, + public sentTransactions?: Transaction[], + public simulateResponse?: SimulateTransaction, + ) { + super(message) + this.name = 'BuildComposerTransactionsError' + } +} + /** TransactionComposer helps you compose and execute transactions as a transaction group. */ export class TransactionComposer { - /** Signer used to represent a lack of signer */ - private static NULL_SIGNER: algosdk.TransactionSigner = algosdk.makeEmptyTransactionSigner() - - /** The ATC used to compose the group */ - private atc = new algosdk.AtomicTransactionComposer() - - /** Map of transaction index in the atc to a max logical fee. - * This is set using the value of either maxFee or staticFee. - */ - private txnMaxFees: Map = new Map() - /** Transactions that have not yet been composed */ private txns: Txn[] = [] @@ -582,6 +266,17 @@ export class TransactionComposer { private errorTransformers: ErrorTransformer[] + private composerConfig: TransactionComposerConfig + + private transactionsWithSigners?: TransactionWithSigner[] + + private signedTransactions?: SignedTransaction[] + + // Note: This doesn't need to be a private field of this class + // It has been done this way so that another process can manipulate this values, i.e. `legacySendTransactionBridge` + // Once the legacy bridges are removed, this can be calculated on the fly + private methodCalls: Map = new Map() + private async transformError(originalError: unknown): Promise { // Transformers only work with Error instances, so immediately return anything else if (!(originalError instanceof Error)) { @@ -618,6 +313,141 @@ export class TransactionComposer { this.defaultValidityWindowIsExplicit = params.defaultValidityWindow !== undefined this.appManager = params.appManager ?? new AppManager(params.algod) this.errorTransformers = params.errorTransformers ?? [] + this.composerConfig = params.composerConfig ?? { + coverAppCallInnerTransactionFees: false, + populateAppCallResources: true, + } + } + + private cloneTransaction(txn: Txn): Txn { + // The transaction params aren't meant to be mutated therefore a shallow clone is ok here + // Only exceptions are txn and asyncTxn where they are encoded, then decoded + switch (txn.type) { + case 'pay': + return { + type: 'pay', + data: { ...txn.data }, + } + case 'assetCreate': + return { + type: 'assetCreate', + data: { ...txn.data }, + } + case 'assetConfig': + return { + type: 'assetConfig', + data: { ...txn.data }, + } + case 'assetFreeze': + return { + type: 'assetFreeze', + data: { ...txn.data }, + } + case 'assetDestroy': + return { + type: 'assetDestroy', + data: { ...txn.data }, + } + case 'assetTransfer': + return { + type: 'assetTransfer', + data: { ...txn.data }, + } + case 'assetOptIn': + return { + type: 'assetOptIn', + data: { ...txn.data }, + } + case 'assetOptOut': + return { + type: 'assetOptOut', + data: { ...txn.data }, + } + case 'appCall': + return { + type: 'appCall', + data: { ...txn.data }, + } + case 'keyReg': + return { + type: 'keyReg', + data: { ...txn.data }, + } + case 'txn': { + const { txn: transaction, signer, maxFee } = txn.data + const encoded = encodeTransactionRaw(transaction) + const clonedTxn = decodeTransaction(encoded) + delete clonedTxn.group + return { + type: 'txn', + data: { + txn: clonedTxn, + signer, + maxFee, + }, + } + } + case 'asyncTxn': { + const { txn: txnPromise, signer, maxFee } = txn.data + // Create a new promise that resolves to a deep cloned transaction without the group field + const newTxnPromise = txnPromise.then((resolvedTxn) => { + const encoded = encodeTransactionRaw(resolvedTxn) + const clonedTxn = decodeTransaction(encoded) + delete clonedTxn.group + return clonedTxn + }) + return { + type: 'asyncTxn', + data: { + txn: newTxnPromise, + signer, + maxFee: maxFee, + }, + } + } + case 'methodCall': + return { + type: 'methodCall', + data: { ...txn.data }, + } + } + } + + private push(...txns: Txn[]): void { + if (this.transactionsWithSigners) { + throw new Error('Cannot add new transactions after building') + } + const newSize = this.txns.length + txns.length + if (newSize > MAX_TRANSACTION_GROUP_SIZE) { + throw new Error( + `Adding ${txns.length} transaction(s) would exceed the maximum group size. Current: ${this.txns.length}, Maximum: ${MAX_TRANSACTION_GROUP_SIZE}`, + ) + } + this.txns.push(...txns) + } + + public clone(composerConfig?: TransactionComposerConfig) { + const newComposer = new TransactionComposer({ + algod: this.algod, + getSuggestedParams: this.getSuggestedParams, + getSigner: this.getSigner, + defaultValidityWindow: this.defaultValidityWindow, + appManager: this.appManager, + errorTransformers: this.errorTransformers, + composerConfig: { + ...this.composerConfig, + ...composerConfig, + }, + }) + + this.txns.forEach((txn) => { + newComposer.txns.push(this.cloneTransaction(txn)) + }) + + newComposer.methodCalls = new Map(this.methodCalls) + newComposer.defaultValidityWindowIsExplicit = this.defaultValidityWindowIsExplicit + + return newComposer } /** @@ -641,10 +471,43 @@ export class TransactionComposer { * ``` */ addTransaction(transaction: Transaction, signer?: TransactionSigner): TransactionComposer { - this.txns.push({ - txn: transaction, - signer: signer ?? this.getSigner(transaction.sender), - type: 'txnWithSigner', + if (transaction.group) { + throw new Error('Cannot add a transaction to the composer because it is already in a group') + } + this.push({ + data: { + txn: transaction, + signer: signer ?? this.getSigner(transaction.sender), + }, + type: 'txn', + }) + + return this + } + + /** + * Add another transaction composer to the current transaction composer. + * The transaction params of the input transaction composer will be added. + * If the input transaction composer is updated, it won't affect the current transaction composer. + * @param composer The transaction composer to add + * @returns The composer so you can chain method calls + * @example + * ```typescript + * const innerComposer = algorand.newGroup() + * .addPayment({ sender: 'SENDER', receiver: 'RECEIVER', amount: (1).algo() }) + * .addPayment({ sender: 'SENDER', receiver: 'RECEIVER', amount: (2).algo() }) + * + * composer.addTransactionComposer(innerComposer) + * ``` + */ + public addTransactionComposer(composer: TransactionComposer): TransactionComposer { + const currentIndex = this.txns.length + const clonedTxns = composer.txns.map((txn) => this.cloneTransaction(txn)) + this.push(...clonedTxns) + + // Copy methodCalls from the target composer, adjusting indices + composer.methodCalls.forEach((method, index) => { + this.methodCalls.set(currentIndex + index, method) }) return this @@ -684,7 +547,7 @@ export class TransactionComposer { * }) */ addPayment(params: PaymentParams): TransactionComposer { - this.txns.push({ ...params, type: 'pay' }) + this.push({ data: params, type: 'pay' }) return this } @@ -725,7 +588,7 @@ export class TransactionComposer { * }) */ addAssetCreate(params: AssetCreateParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetCreate' }) + this.push({ data: params, type: 'assetCreate' }) return this } @@ -760,7 +623,7 @@ export class TransactionComposer { * }) */ addAssetConfig(params: AssetConfigParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetConfig' }) + this.push({ data: params, type: 'assetConfig' }) return this } @@ -794,7 +657,7 @@ export class TransactionComposer { * ``` */ addAssetFreeze(params: AssetFreezeParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetFreeze' }) + this.push({ data: params, type: 'assetFreeze' }) return this } @@ -826,7 +689,7 @@ export class TransactionComposer { * ``` */ addAssetDestroy(params: AssetDestroyParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetDestroy' }) + this.push({ data: params, type: 'assetDestroy' }) return this } @@ -863,7 +726,7 @@ export class TransactionComposer { * ``` */ addAssetTransfer(params: AssetTransferParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetTransfer' }) + this.push({ data: params, type: 'assetTransfer' }) return this } @@ -895,7 +758,7 @@ export class TransactionComposer { * ``` */ addAssetOptIn(params: AssetOptInParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetOptIn' }) + this.push({ data: params, type: 'assetOptIn' }) return this } @@ -933,7 +796,7 @@ export class TransactionComposer { * ``` */ addAssetOptOut(params: AssetOptOutParams): TransactionComposer { - this.txns.push({ ...params, type: 'assetOptOut' }) + this.push({ data: params, type: 'assetOptOut' }) return this } @@ -988,7 +851,7 @@ export class TransactionComposer { * ``` */ addAppCreate(params: AppCreateParams): TransactionComposer { - this.txns.push({ ...params, type: 'appCall' }) + this.push({ data: params, type: 'appCall' }) return this } @@ -1030,7 +893,7 @@ export class TransactionComposer { * ``` */ addAppUpdate(params: AppUpdateParams): TransactionComposer { - this.txns.push({ ...params, type: 'appCall', onComplete: OnApplicationComplete.UpdateApplication }) + this.push({ data: { ...params, onComplete: OnApplicationComplete.UpdateApplication }, type: 'appCall' }) return this } @@ -1070,7 +933,7 @@ export class TransactionComposer { * ``` */ addAppDelete(params: AppDeleteParams): TransactionComposer { - this.txns.push({ ...params, type: 'appCall', onComplete: OnApplicationComplete.DeleteApplication }) + this.push({ data: { ...params, onComplete: OnApplicationComplete.DeleteApplication }, type: 'appCall' }) return this } @@ -1112,7 +975,7 @@ export class TransactionComposer { * ``` */ addAppCall(params: AppCallParams): TransactionComposer { - this.txns.push({ ...params, type: 'appCall' }) + this.push({ data: params, type: 'appCall' }) return this } @@ -1173,7 +1036,23 @@ export class TransactionComposer { * ``` */ addAppCreateMethodCall(params: AppCreateMethodCall) { - this.txns.push({ ...params, type: 'methodCall' }) + const txnArgs = extractComposerTransactionsFromAppMethodCallParams(params) + const currentIndex = this.txns.length + + // Push all transaction arguments and the method call itself + this.push(...txnArgs, { + data: { ...params, args: processAppMethodCallArgs(params.args) }, + type: 'methodCall', + }) + + // Set method calls for any method call arguments + txnArgs.forEach((txn, index) => { + if (txn.type === 'methodCall') { + this.methodCalls.set(currentIndex + index, txn.data.method) + } + }) + // Set method call for the main transaction + this.methodCalls.set(currentIndex + txnArgs.length, params.method) return this } @@ -1226,7 +1105,23 @@ export class TransactionComposer { * ``` */ addAppUpdateMethodCall(params: AppUpdateMethodCall) { - this.txns.push({ ...params, type: 'methodCall', onComplete: OnApplicationComplete.UpdateApplication }) + const txnArgs = extractComposerTransactionsFromAppMethodCallParams(params) + const currentIndex = this.txns.length + + // Push all transaction arguments and the method call itself + this.push(...txnArgs, { + data: { ...params, args: processAppMethodCallArgs(params.args), onComplete: OnApplicationComplete.UpdateApplication }, + type: 'methodCall', + }) + + // Set method calls for any method call arguments + txnArgs.forEach((txn, index) => { + if (txn.type === 'methodCall') { + this.methodCalls.set(currentIndex + index, txn.data.method) + } + }) + // Set method call for the main transaction + this.methodCalls.set(currentIndex + txnArgs.length, params.method) return this } @@ -1277,7 +1172,23 @@ export class TransactionComposer { * ``` */ addAppDeleteMethodCall(params: AppDeleteMethodCall) { - this.txns.push({ ...params, type: 'methodCall', onComplete: OnApplicationComplete.DeleteApplication }) + const txnArgs = extractComposerTransactionsFromAppMethodCallParams(params) + const currentIndex = this.txns.length + + // Push all transaction arguments and the method call itself + this.push(...txnArgs, { + data: { ...params, args: processAppMethodCallArgs(params.args), onComplete: OnApplicationComplete.DeleteApplication }, + type: 'methodCall', + }) + + // Set method calls for any method call arguments + txnArgs.forEach((txn, index) => { + if (txn.type === 'methodCall') { + this.methodCalls.set(currentIndex + index, txn.data.method) + } + }) + // Set method call for the main transaction + this.methodCalls.set(currentIndex + txnArgs.length, params.method) return this } @@ -1328,7 +1239,23 @@ export class TransactionComposer { * ``` */ addAppCallMethodCall(params: AppCallMethodCall) { - this.txns.push({ ...params, type: 'methodCall' }) + const txnArgs = extractComposerTransactionsFromAppMethodCallParams(params) + const currentIndex = this.txns.length + + // Push all transaction arguments and the method call itself + this.push(...txnArgs, { + data: { ...params, args: processAppMethodCallArgs(params.args) }, + type: 'methodCall', + }) + + // Set method calls for any method call arguments + txnArgs.forEach((txn, index) => { + if (txn.type === 'methodCall') { + this.methodCalls.set(currentIndex + index, txn.data.method) + } + }) + // Set method call for the main transaction + this.methodCalls.set(currentIndex + txnArgs.length, params.method) return this } @@ -1374,7 +1301,7 @@ export class TransactionComposer { * ``` */ addOnlineKeyRegistration(params: OnlineKeyRegistrationParams): TransactionComposer { - this.txns.push({ ...params, type: 'keyReg' }) + this.push({ data: params, type: 'keyReg' }) return this } @@ -1409,625 +1336,486 @@ export class TransactionComposer { * ``` */ addOfflineKeyRegistration(params: OfflineKeyRegistrationParams): TransactionComposer { - this.txns.push({ ...params, type: 'keyReg' }) + this.push({ data: params, type: 'keyReg' }) return this } /** - * Add the transactions within an `AtomicTransactionComposer` to the transaction group. - * @param atc The `AtomicTransactionComposer` to build transactions from and add to the group - * @returns The composer so you can chain method calls + * Get the number of transactions currently added to this composer. + * @returns The number of transactions currently added to this composer + */ + count() { + return this.txns.length + } + + /** + * Build the transaction composer. + * + * This method performs resource population and inner transaction fee coverage if these options are set in the composer. + * + * Once this method is called, no further transactions will be able to be added. + * You can safely call this method multiple times to get the same result. + * @returns The built transaction composer, the transactions and any corresponding method calls * @example * ```typescript - * const atc = new AtomicTransactionComposer() - * .addPayment({ sender: 'SENDERADDRESS', receiver: 'RECEIVERADDRESS', amount: 1000n }) - * composer.addAtc(atc) + * const { transactions, methodCalls } = await composer.build() * ``` */ - addAtc(atc: algosdk.AtomicTransactionComposer): TransactionComposer { - this.txns.push({ atc, type: 'atc' }) - return this - } + public async build() { + if (!this.transactionsWithSigners) { + const suggestedParams = await this.getSuggestedParams() + const builtTransactions = await this._buildTransactions(suggestedParams) - /** Build an ATC and return transactions ready to be incorporated into a broader set of transactions this composer is composing */ - private buildAtc(atc: algosdk.AtomicTransactionComposer): TransactionWithSignerAndContext[] { - const group = atc.buildGroup() + const groupAnalysis = + (this.composerConfig.coverAppCallInnerTransactionFees || this.composerConfig.populateAppCallResources) && + builtTransactions.transactions.some((txn) => txn.type === TransactionType.AppCall) + ? await this.analyzeGroupRequirements(builtTransactions.transactions, suggestedParams, this.composerConfig) + : undefined - const txnWithSigners = group.map((ts, idx) => { - // Remove underlying group ID from the transaction since it will be re-grouped when this TransactionComposer is built - ts.txn.group = undefined - // If this was a method call return the ABIMethod for later - if (atc['methodCalls'].get(idx)) { - return { - ...ts, - context: { abiMethod: atc['methodCalls'].get(idx) as algosdk.ABIMethod }, - } - } - return { - ...ts, - context: {}, + try { + this.populateTransactionAndGroupResources(builtTransactions.transactions, groupAnalysis) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + throw new BuildComposerTransactionsError(err.message ?? 'Failed to populate transaction and group resources') } - }) + this.transactionsWithSigners = this.gatherSigners(builtTransactions) + } - return txnWithSigners + return { + transactions: this.transactionsWithSigners, + methodCalls: this.methodCalls, + } } - private commonTxnBuildStep( - buildTxn: (params: TParams) => Transaction, - params: CommonTransactionParams, - txnParams: TParams, - ): TransactionWithContext { - // We are going to mutate suggested params, let's create a clone first - txnParams.suggestedParams = { ...txnParams.suggestedParams } - - if (params.lease) txnParams.lease = encodeLease(params.lease)! satisfies Transaction['lease'] - if (params.rekeyTo) txnParams.rekeyTo = params.rekeyTo.toString() satisfies Transaction['rekeyTo'] - const encoder = new TextEncoder() - if (params.note) - txnParams.note = (typeof params.note === 'string' ? encoder.encode(params.note) : params.note) satisfies Transaction['note'] + private async _buildTransactions(suggestedParams: SuggestedParams) { + const defaultValidityWindow = + !this.defaultValidityWindowIsExplicit && genesisIdIsLocalNet(suggestedParams.genesisId ?? 'unknown') + ? 1000n + : this.defaultValidityWindow + const signers = new Map() + const transactions = new Array() + + let transactionIndex = 0 + for (const ctxn of this.txns) { + if (ctxn.type === 'txn') { + transactions.push(ctxn.data.txn) + if (ctxn.data.signer) { + signers.set(transactionIndex, ctxn.data.signer) + } + transactionIndex++ + } else if (ctxn.type === 'asyncTxn') { + transactions.push(await ctxn.data.txn) + if (ctxn.data.signer) { + signers.set(transactionIndex, ctxn.data.signer) + } + transactionIndex++ + } else { + let transaction: Transaction + const header = buildTransactionHeader(ctxn.data, suggestedParams, defaultValidityWindow) + const calculateFee = header?.fee === undefined + + switch (ctxn.type) { + case 'pay': + transaction = buildPayment(ctxn.data, header) + break + case 'assetCreate': + transaction = buildAssetCreate(ctxn.data, header) + break + case 'assetConfig': + transaction = buildAssetConfig(ctxn.data, header) + break + case 'assetFreeze': + transaction = buildAssetFreeze(ctxn.data, header) + break + case 'assetDestroy': + transaction = buildAssetDestroy(ctxn.data, header) + break + case 'assetTransfer': + transaction = buildAssetTransfer(ctxn.data, header) + break + case 'assetOptIn': + transaction = buildAssetOptIn(ctxn.data, header) + break + case 'assetOptOut': + transaction = buildAssetOptOut(ctxn.data, header) + break + case 'appCall': + if (!('appId' in ctxn.data)) { + transaction = await buildAppCreate(ctxn.data, this.appManager, header) + } else if ('approvalProgram' in ctxn.data && 'clearStateProgram' in ctxn.data) { + transaction = await buildAppUpdate(ctxn.data, this.appManager, header) + } else { + transaction = buildAppCall(ctxn.data, header) + } + break + case 'keyReg': + transaction = buildKeyReg(ctxn.data, header) + break + case 'methodCall': + if (!('appId' in ctxn.data)) { + transaction = await buildAppCreateMethodCall(ctxn.data, this.appManager, header) + } else if ('approvalProgram' in ctxn.data && 'clearStateProgram' in ctxn.data) { + transaction = await buildAppUpdateMethodCall(ctxn.data, this.appManager, header) + } else { + transaction = await buildAppCallMethodCall(ctxn.data, header) + } + break + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw new Error(`Unsupported transaction type: ${(ctxn as any).type}`) + } - if (params.firstValidRound) { - txnParams.suggestedParams.firstValid = params.firstValidRound - } + if (calculateFee) { + transaction = assignFee(transaction, { + feePerByte: suggestedParams.fee, + minFee: suggestedParams.minFee, + extraFee: ctxn.data.extraFee?.microAlgos, + maxFee: ctxn.data.maxFee?.microAlgos, + }) + } - if (params.lastValidRound) { - txnParams.suggestedParams.lastValid = params.lastValidRound - } else { - // If the validity window isn't set in this transaction or by default and we are pointing at - // LocalNet set a bigger window to avoid dead transactions - const window = params.validityWindow - ? BigInt(params.validityWindow) - : !this.defaultValidityWindowIsExplicit && genesisIdIsLocalNet(txnParams.suggestedParams.genesisId ?? 'unknown') - ? 1000n - : this.defaultValidityWindow - txnParams.suggestedParams.lastValid = BigInt(txnParams.suggestedParams.firstValid) + window - } + transactions.push(transaction) - if (params.staticFee !== undefined && params.extraFee !== undefined) { - throw Error('Cannot set both staticFee and extraFee') + if (ctxn.data.signer) { + const signer = 'signer' in ctxn.data.signer ? ctxn.data.signer.signer : ctxn.data.signer + signers.set(transactionIndex, signer) + } + transactionIndex++ + } } - let txn = buildTxn(txnParams) - - if (params.staticFee !== undefined) { - txn.fee = params.staticFee.microAlgos - } else { - txn = assignFee(txn, { - feePerByte: txnParams.suggestedParams.fee, - minFee: txnParams.suggestedParams.minFee, - extraFee: params.extraFee?.microAlgos, - maxFee: params.maxFee?.microAlgos, - }) + // Validate that the total group size doesn't exceed the maximum + if (transactions.length > MAX_TRANSACTION_GROUP_SIZE) { + throw new Error(`Transaction group size ${transactions.length} exceeds the maximum limit of ${MAX_TRANSACTION_GROUP_SIZE}`) } - const logicalMaxFee = - params.maxFee !== undefined && params.maxFee.microAlgo > (params.staticFee?.microAlgo ?? 0n) ? params.maxFee : params.staticFee - - return { txn, context: { maxFee: logicalMaxFee } } + return { transactions, methodCalls: this.methodCalls, signers } } /** - * Builds an ABI method call transaction and any other associated transactions represented in the ABI args. - * @param includeSigner Whether to include the actual signer for the transactions. - * If you are just building transactions without signers yet then set this to `false`. + * @deprecated Use `composer.build()` instead + * Compose all of the transactions without signers and return the transaction objects directly along with any ABI method calls. + * + * @returns The array of built transactions and any corresponding method calls + * @example + * ```typescript + * const { transactions, methodCalls, signers } = await composer.buildTransactions() + * ``` */ - private async buildMethodCall( - params: AppCallMethodCall | AppCreateMethodCall | AppUpdateMethodCall, - suggestedParams: SuggestedParams, - includeSigner: boolean, - ): Promise { - const methodArgs: (algosdk.ABIArgument | TransactionWithSignerAndContext)[] = [] - const transactionsForGroup: TransactionWithSignerAndContext[] = [] - - const isAbiValue = (x: unknown): x is algosdk.ABIValue => { - if (Array.isArray(x)) return x.length == 0 || x.every(isAbiValue) - - return ( - typeof x === 'bigint' || - typeof x === 'boolean' || - typeof x === 'number' || - typeof x === 'string' || - x instanceof Uint8Array || - x instanceof algosdk.Address - ) - } + public async buildTransactions(): Promise { + const buildResult = await this.build() - for (let i = (params.args ?? []).length - 1; i >= 0; i--) { - const arg = params.args![i] - if (arg === undefined) { - // An undefined transaction argument signals that the value will be supplied by a method call argument - if (algosdk.abiTypeIsTransaction(params.method.args[i].type) && transactionsForGroup.length > 0) { - // Move the last transaction from the group to the method call arguments to appease algosdk - const placeholderTransaction = transactionsForGroup.splice(-1, 1)[0] - methodArgs.push(placeholderTransaction) - continue - } - - throw Error(`No value provided for argument ${i + 1} within call to ${params.method.name}`) - } + const transactions = buildResult.transactions.map((txnWithSigner) => txnWithSigner.txn) + transactions.forEach((txn) => { + delete txn.group + }) - if (isAbiValue(arg)) { - methodArgs.push(arg) - continue - } + const signers = new Map() + buildResult.transactions.forEach((txnWithSigner, index) => { + signers.set(index, txnWithSigner.signer) + }) - if (isTransactionWithSigner(arg)) { - methodArgs.push(arg) - continue - } + return { + transactions, + methodCalls: buildResult.methodCalls, + signers, + } + } - if ('method' in arg) { - const tempTxnWithSigners = await this.buildMethodCall(arg, suggestedParams, includeSigner) - // If there is any transaction args, add to the atc - // Everything else should be added as method args + private populateTransactionAndGroupResources(transactions: Transaction[], groupAnalysis?: GroupAnalysis): Transaction[] { + if (groupAnalysis) { + // Process fee adjustments + let surplusGroupFees = 0n + const transactionAnalysis: Array<{ + groupIndex: number + requiredFeeDelta?: FeeDelta + priority: FeePriority + unnamedResourcesAccessed?: SimulateUnnamedResourcesAccessed + }> = [] + + // Process fee adjustments + groupAnalysis.transactions.forEach((txnAnalysis, groupIndex) => { + // Accumulate surplus fees + if (txnAnalysis.requiredFeeDelta && FeeDelta.isSurplus(txnAnalysis.requiredFeeDelta)) { + surplusGroupFees += FeeDelta.amount(txnAnalysis.requiredFeeDelta) + } - methodArgs.push(...tempTxnWithSigners.slice(-1)) // Add the method call itself as a method arg - transactionsForGroup.push(...tempTxnWithSigners.slice(0, -1).reverse()) // Add any transaction arguments to the atc - continue - } + // Calculate priority and add to transaction info + const ctxn = this.txns[groupIndex] + const txn = transactions[groupIndex] + const logicalMaxFee = getLogicalMaxFee(ctxn) + const isImmutableFee = logicalMaxFee !== undefined && logicalMaxFee === (txn.fee || 0n) + + let priority = FeePriority.Covered + if (txnAnalysis.requiredFeeDelta && FeeDelta.isDeficit(txnAnalysis.requiredFeeDelta)) { + const deficitAmount = FeeDelta.amount(txnAnalysis.requiredFeeDelta) + if (isImmutableFee || txn.type !== TransactionType.AppCall) { + // High priority: transactions that can't be modified + priority = FeePriority.ImmutableDeficit(deficitAmount) + } else { + // Normal priority: app call transactions that can be modified + priority = FeePriority.ModifiableDeficit(deficitAmount) + } + } - const txn = await arg - methodArgs.push({ - txn, - signer: includeSigner - ? params.signer - ? 'signer' in params.signer - ? params.signer.signer - : params.signer - : this.getSigner(txn.sender) - : TransactionComposer.NULL_SIGNER, + transactionAnalysis.push({ + groupIndex, + requiredFeeDelta: txnAnalysis.requiredFeeDelta, + priority, + unnamedResourcesAccessed: txnAnalysis.unnamedResourcesAccessed, + }) }) - } - const methodAtc = new algosdk.AtomicTransactionComposer() - const maxFees = new Map() + // Sort transactions by priority (highest first) + transactionAnalysis.sort((a, b) => b.priority.compare(a.priority)) + + const indexesWithAccessReferences: number[] = [] + + for (const { groupIndex, requiredFeeDelta, unnamedResourcesAccessed } of transactionAnalysis) { + // Cover any additional fees required for the transactions + if (requiredFeeDelta && FeeDelta.isDeficit(requiredFeeDelta)) { + const deficitAmount = FeeDelta.amount(requiredFeeDelta) + let additionalFeeDelta: FeeDelta | undefined + + if (surplusGroupFees === 0n) { + // No surplus group fees, the transaction must cover its own deficit + additionalFeeDelta = requiredFeeDelta + } else if (surplusGroupFees >= deficitAmount) { + // Surplus fully covers the deficit + surplusGroupFees -= deficitAmount + } else { + // Surplus partially covers the deficit + additionalFeeDelta = FeeDelta.fromBigInt(deficitAmount - surplusGroupFees) + surplusGroupFees = 0n + } - transactionsForGroup.reverse().forEach(({ context, ...txnWithSigner }) => { - methodAtc.addTransaction(txnWithSigner) - const atcIndex = methodAtc.count() - 1 - if (context.abiMethod) { - methodAtc['methodCalls'].set(atcIndex, context.abiMethod) - } - if (context.maxFee !== undefined) { - maxFees.set(atcIndex, context.maxFee) - } - }) + // If there is any additional fee deficit, the transaction must cover it by modifying the fee + if (additionalFeeDelta && FeeDelta.isDeficit(additionalFeeDelta)) { + const additionalDeficitAmount = FeeDelta.amount(additionalFeeDelta) - // If any of the args are method call transactions, add that info to the methodAtc - methodArgs - .filter((arg) => { - if (typeof arg === 'object' && 'context' in arg) { - const { context, ...txnWithSigner } = arg - return isTransactionWithSigner(txnWithSigner) - } - return isTransactionWithSigner(arg) - }) - .reverse() - .forEach((arg, idx) => { - if (typeof arg === 'object' && 'context' in arg && arg.context) { - const atcIndex = methodAtc.count() + idx - if (arg.context.abiMethod) { - methodAtc['methodCalls'].set(atcIndex, arg.context.abiMethod) - } - if (arg.context.maxFee !== undefined) { - maxFees.set(atcIndex, arg.context.maxFee) + if (transactions[groupIndex].type === TransactionType.AppCall) { + const currentFee = transactions[groupIndex].fee || 0n + const transactionFee = currentFee + additionalDeficitAmount + + const logicalMaxFee = getLogicalMaxFee(this.txns[groupIndex]) + if (!logicalMaxFee || transactionFee > logicalMaxFee) { + throw new Error( + `Calculated transaction fee ${transactionFee} µALGO is greater than max of ${logicalMaxFee ?? 0n} for transaction ${groupIndex}`, + ) + } + + transactions[groupIndex].fee = transactionFee + } else { + throw new Error( + `An additional fee of ${additionalDeficitAmount} µALGO is required for non app call transaction ${groupIndex}`, + ) + } } } - }) - const appId = Number('appId' in params ? params.appId : 0n) - const approvalProgram = - 'approvalProgram' in params - ? typeof params.approvalProgram === 'string' - ? (await this.appManager.compileTeal(params.approvalProgram)).compiledBase64ToBytes - : params.approvalProgram - : undefined - const clearStateProgram = - 'clearStateProgram' in params - ? typeof params.clearStateProgram === 'string' - ? (await this.appManager.compileTeal(params.clearStateProgram)).compiledBase64ToBytes - : params.clearStateProgram - : undefined - - // If accessReferences is provided, we should not pass legacy foreign arrays - const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 - - const txnParams = { - appID: appId, - sender: params.sender, - suggestedParams, - onComplete: params.onComplete ?? OnApplicationComplete.NoOp, - ...(hasAccessReferences - ? { access: params.accessReferences } - : { - appAccounts: params.accountReferences, - appForeignApps: params.appReferences?.map((x) => Number(x)), - appForeignAssets: params.assetReferences?.map((x) => Number(x)), - boxes: params.boxReferences?.map(AppManager.getBoxReference), - }), - approvalProgram, - clearProgram: clearStateProgram, - extraPages: - appId === 0 - ? 'extraProgramPages' in params && params.extraProgramPages !== undefined - ? params.extraProgramPages - : approvalProgram - ? calculateExtraProgramPages(approvalProgram, clearStateProgram) - : 0 - : undefined, - numLocalInts: appId === 0 ? ('schema' in params ? (params.schema?.localInts ?? 0) : 0) : undefined, - numLocalByteSlices: appId === 0 ? ('schema' in params ? (params.schema?.localByteSlices ?? 0) : 0) : undefined, - numGlobalInts: appId === 0 ? ('schema' in params ? (params.schema?.globalInts ?? 0) : 0) : undefined, - numGlobalByteSlices: appId === 0 ? ('schema' in params ? (params.schema?.globalByteSlices ?? 0) : 0) : undefined, - method: params.method, - signer: includeSigner - ? params.signer - ? 'signer' in params.signer - ? params.signer.signer - : params.signer - : this.getSigner(params.sender) - : TransactionComposer.NULL_SIGNER, - methodArgs: methodArgs - .map((arg) => { - if (typeof arg === 'object' && 'context' in arg) { - const { context, ...txnWithSigner } = arg - return txnWithSigner + // Apply transaction-level resource population + if (unnamedResourcesAccessed && transactions[groupIndex].type === TransactionType.AppCall) { + const hasAccessReferences = + transactions[groupIndex].appCall?.accessReferences && transactions[groupIndex].appCall?.accessReferences?.length + if (!hasAccessReferences) { + populateTransactionResources(transactions[groupIndex], unnamedResourcesAccessed, groupIndex) + } else { + indexesWithAccessReferences.push(groupIndex) } - return arg - }) - .reverse(), - // note, lease, and rekeyTo are set in the common build step - note: undefined, - lease: undefined, - rekeyTo: undefined, - } - - // Build the transaction - const result = this.commonTxnBuildStep( - (txnParams) => { - methodAtc.addMethodCall(txnParams) - return methodAtc.buildGroup()[methodAtc.count() - 1].txn - }, - params, - txnParams, - ) - - // Process the ATC to get a set of transactions ready for broader grouping - return this.buildAtc(methodAtc).map(({ context: _context, ...txnWithSigner }, idx) => { - const maxFee = idx === methodAtc.count() - 1 ? result.context.maxFee : maxFees.get(idx) - // TODO: PD - review this way of assigning fee - const fee = idx === methodAtc.count() - 1 ? result.txn.fee : txnWithSigner.txn.fee - const context = { - ..._context, // Adds method context info - maxFee, + } } - return { - signer: txnWithSigner.signer, - txn: { - ...txnWithSigner.txn, - fee: fee, - }, - context, + if (indexesWithAccessReferences.length > 0) { + Config.logger.warn( + `Resource population will be skipped for transaction indexes ${indexesWithAccessReferences.join(', ')} as they use access references.`, + ) } - }) - } - - private buildPayment(params: PaymentParams, suggestedParams: SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makePaymentTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - receiver: params.receiver, - amount: params.amount.microAlgo, - closeRemainderTo: params.closeRemainderTo, - suggestedParams, - }) - } - private buildAssetCreate(params: AssetCreateParams, suggestedParams: SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makeAssetCreateTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - total: params.total, - decimals: params.decimals ?? 0, - assetName: params.assetName, - unitName: params.unitName, - assetURL: params.url, - defaultFrozen: params.defaultFrozen ?? false, - assetMetadataHash: typeof params.metadataHash === 'string' ? Buffer.from(params.metadataHash, 'utf-8') : params.metadataHash, - manager: params.manager, - reserve: params.reserve, - freeze: params.freeze, - clawback: params.clawback, - suggestedParams, - }) - } + // Apply group-level resource population + if (groupAnalysis.unnamedResourcesAccessed) { + populateGroupResources(transactions, groupAnalysis.unnamedResourcesAccessed) + } + } - private buildAssetConfig(params: AssetConfigParams, suggestedParams: SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makeAssetConfigTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - assetIndex: params.assetId, - suggestedParams, - manager: params.manager, - reserve: params.reserve, - freeze: params.freeze, - clawback: params.clawback, - strictEmptyAddressChecking: false, - }) + if (transactions.length > 1) { + const groupedTransactions = groupTransactions(transactions) + // Mutate the input transactions so that the group is updated for any transaction passed into the composer + transactions.forEach((t) => (t.group = groupedTransactions[0].group)) + return transactions + } else { + return transactions + } } - private buildAssetDestroy(params: AssetDestroyParams, suggestedParams: SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makeAssetDestroyTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - assetIndex: params.assetId, - suggestedParams, + private gatherSigners(builtTransactions: BuiltTransactions): TransactionWithSigner[] { + return builtTransactions.transactions.map((txn, index) => { + return { + txn, + signer: builtTransactions.signers.get(index) ?? this.getSigner(txn.sender), + } }) } - private buildAssetFreeze(params: AssetFreezeParams, suggestedParams: SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makeAssetFreezeTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - assetIndex: params.assetId, - freezeTarget: params.account, - frozen: params.frozen, - suggestedParams, - }) - } + private async analyzeGroupRequirements( + transactions: Transaction[], + suggestedParams: SuggestedParams, + analysisParams: TransactionComposerConfig, + ): Promise { + const appCallIndexesWithoutMaxFees: number[] = [] + + let transactionsToSimulate = transactions.map((txn, groupIndex) => { + const ctxn = this.txns[groupIndex] + const txnToSimulate = { ...txn } + delete txnToSimulate.group + if (analysisParams.coverAppCallInnerTransactionFees && txn.type === TransactionType.AppCall) { + const logicalMaxFee = getLogicalMaxFee(ctxn) + if (logicalMaxFee !== undefined) { + txnToSimulate.fee = logicalMaxFee + } else { + appCallIndexesWithoutMaxFees.push(groupIndex) + } + } - private buildAssetTransfer(params: AssetTransferParams, suggestedParams: SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - receiver: params.receiver, - assetIndex: params.assetId, - amount: params.amount, - suggestedParams, - closeRemainderTo: params.closeAssetTo, - assetSender: params.clawbackTarget, + return txnToSimulate }) - } - private async buildAppCall(params: AppCallParams | AppUpdateParams | AppCreateParams, suggestedParams: SuggestedParams) { - const appId = 'appId' in params ? params.appId : 0n - const approvalProgram = - 'approvalProgram' in params - ? typeof params.approvalProgram === 'string' - ? (await this.appManager.compileTeal(params.approvalProgram)).compiledBase64ToBytes - : params.approvalProgram - : undefined - const clearStateProgram = - 'clearStateProgram' in params - ? typeof params.clearStateProgram === 'string' - ? (await this.appManager.compileTeal(params.clearStateProgram)).compiledBase64ToBytes - : params.clearStateProgram - : undefined - - // If accessReferences is provided, we should not pass legacy foreign arrays - const hasAccessReferences = params.accessReferences && params.accessReferences.length > 0 - - const sdkParams = { - sender: params.sender, - suggestedParams, - appArgs: params.args, - onComplete: params.onComplete ?? OnApplicationComplete.NoOp, - ...(hasAccessReferences - ? { access: params.accessReferences } - : { - accounts: params.accountReferences, - foreignApps: params.appReferences?.map((x) => Number(x)), - foreignAssets: params.assetReferences?.map((x) => Number(x)), - boxes: params.boxReferences?.map(AppManager.getBoxReference), - }), - approvalProgram, - clearProgram: clearStateProgram, + // Regroup the transactions, as the transactions have likely been adjusted + if (transactionsToSimulate.length > 1) { + transactionsToSimulate = groupTransactions(transactionsToSimulate) } - if (appId === 0n) { - if (sdkParams.approvalProgram === undefined || sdkParams.clearProgram === undefined) { - throw new Error('approvalProgram and clearStateProgram are required for application creation') - } - - return this.commonTxnBuildStep(algosdk.makeApplicationCreateTxnFromObject, params, { - ...sdkParams, - extraPages: - 'extraProgramPages' in params && params.extraProgramPages !== undefined - ? params.extraProgramPages - : calculateExtraProgramPages(approvalProgram!, clearStateProgram!), - numLocalInts: 'schema' in params ? (params.schema?.localInts ?? 0) : 0, - numLocalByteSlices: 'schema' in params ? (params.schema?.localByteSlices ?? 0) : 0, - numGlobalInts: 'schema' in params ? (params.schema?.globalInts ?? 0) : 0, - numGlobalByteSlices: 'schema' in params ? (params.schema?.globalByteSlices ?? 0) : 0, - approvalProgram: approvalProgram!, - clearProgram: clearStateProgram!, - }) - } else { - return this.commonTxnBuildStep(algosdk.makeApplicationCallTxnFromObject, params, { ...sdkParams, appIndex: appId }) - } - } - - private buildKeyReg(params: OnlineKeyRegistrationParams | OfflineKeyRegistrationParams, suggestedParams: SuggestedParams) { - if ('voteKey' in params) { - return this.commonTxnBuildStep(algosdk.makeKeyRegistrationTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - voteKey: params.voteKey, - selectionKey: params.selectionKey, - voteFirst: params.voteFirst, - voteLast: params.voteLast, - voteKeyDilution: params.voteKeyDilution, - suggestedParams, - nonParticipation: false, - stateProofKey: params.stateProofKey, - }) + // Check for required max fees on app calls when fee coverage is enabled + if (analysisParams.coverAppCallInnerTransactionFees && appCallIndexesWithoutMaxFees.length > 0) { + throw new BuildComposerTransactionsError( + `Please provide a maxFee for each app call transaction when coverAppCallInnerTransactionFees is enabled. Required for transaction ${appCallIndexesWithoutMaxFees.join(', ')}`, + ) } - return this.commonTxnBuildStep(algosdk.makeKeyRegistrationTxnWithSuggestedParamsFromObject, params, { - sender: params.sender, - suggestedParams, - nonParticipation: params.preventAccountFromEverParticipatingAgain, - }) - } - - /** Builds all transaction types apart from `txnWithSigner`, `atc` and `methodCall` since those ones can have custom signers that need to be retrieved. */ - private async buildTxn(txn: Txn, suggestedParams: SuggestedParams): Promise { - switch (txn.type) { - case 'pay': - return [this.buildPayment(txn, suggestedParams)] - case 'assetCreate': - return [this.buildAssetCreate(txn, suggestedParams)] - case 'appCall': - return [await this.buildAppCall(txn, suggestedParams)] - case 'assetConfig': - return [this.buildAssetConfig(txn, suggestedParams)] - case 'assetDestroy': - return [this.buildAssetDestroy(txn, suggestedParams)] - case 'assetFreeze': - return [this.buildAssetFreeze(txn, suggestedParams)] - case 'assetTransfer': - return [this.buildAssetTransfer(txn, suggestedParams)] - case 'assetOptIn': - return [this.buildAssetTransfer({ ...txn, receiver: txn.sender, amount: 0n }, suggestedParams)] - case 'assetOptOut': - return [this.buildAssetTransfer({ ...txn, receiver: txn.sender, amount: 0n, closeAssetTo: txn.creator }, suggestedParams)] - case 'keyReg': - return [this.buildKeyReg(txn, suggestedParams)] - default: - throw Error(`Unsupported txn type`) - } - } + const signedTransactions = transactionsToSimulate.map( + (txn) => + ({ + txn: txn, + signature: EMPTY_SIGNATURE, + }) satisfies SignedTransaction, + ) - private async buildTxnWithSigner(txn: Txn, suggestedParams: SuggestedParams): Promise { - if (txn.type === 'txnWithSigner') { - return [ + const simulateRequest: SimulateRequest = { + txnGroups: [ { - ...txn, - context: {}, + txns: signedTransactions, }, - ] + ], + allowUnnamedResources: true, + allowEmptySignatures: true, + fixSigners: true, + allowMoreLogging: true, + execTraceConfig: { + enable: true, + scratchChange: true, + stackChange: true, + stateChange: true, + }, } - if (txn.type === 'atc') { - return this.buildAtc(txn.atc) - } + const response = await this.algod.simulateTransaction(simulateRequest) + const groupResponse = response.txnGroups[0] + + // Handle any simulation failures + if (groupResponse.failureMessage) { + if (analysisParams.coverAppCallInnerTransactionFees && groupResponse.failureMessage.includes('fee too small')) { + throw new BuildComposerTransactionsError( + 'Fees were too small to resolve execution info via simulate. You may need to increase an app call transaction maxFee.', + transactionsToSimulate, + response, + ) + } - if (txn.type === 'methodCall') { - return await this.buildMethodCall(txn, suggestedParams, true) + throw new BuildComposerTransactionsError( + `Error resolving execution info via simulate in transaction ${groupResponse.failedAt?.join(', ')}: ${groupResponse.failureMessage}`, + transactionsToSimulate, + response, + ) } - const signer = txn.signer ? ('signer' in txn.signer ? txn.signer.signer : txn.signer) : this.getSigner(txn.sender) + const txnAnalysisResults: TransactionAnalysis[] = groupResponse.txnResults.map((simulateTxnResult, groupIndex) => { + const btxn = transactions[groupIndex] - return (await this.buildTxn(txn, suggestedParams)).map(({ txn, context }) => ({ txn, signer, context })) - } + let requiredFeeDelta: FeeDelta | undefined - /** - * Compose all of the transactions without signers and return the transaction objects directly along with any ABI method calls. - * - * @returns The array of built transactions and any corresponding method calls - * @example - * ```typescript - * const { transactions, methodCalls, signers } = await composer.buildTransactions() - * ``` - */ - async buildTransactions(): Promise { - const suggestedParams = await this.getSuggestedParams() - - const transactions: Transaction[] = [] - const methodCalls = new Map() - const signers = new Map() - - for (const txn of this.txns) { - if (!['txnWithSigner', 'atc', 'methodCall'].includes(txn.type)) { - transactions.push(...(await this.buildTxn(txn, suggestedParams)).map((txn) => txn.txn)) - } else { - const transactionsWithSigner = - txn.type === 'txnWithSigner' - ? [txn] - : txn.type === 'atc' - ? this.buildAtc(txn.atc) - : txn.type === 'methodCall' - ? await this.buildMethodCall(txn, suggestedParams, false) - : [] - - transactionsWithSigner.forEach((ts) => { - transactions.push(ts.txn) - const groupIdx = transactions.length - 1 - - if (ts.signer && ts.signer !== TransactionComposer.NULL_SIGNER) { - signers.set(groupIdx, ts.signer) - } - if ('context' in ts && ts.context.abiMethod) { - methodCalls.set(groupIdx, ts.context.abiMethod) - } + if (analysisParams.coverAppCallInnerTransactionFees) { + const minTxnFee = calculateFee(btxn, { + feePerByte: suggestedParams.fee, + minFee: suggestedParams.minFee, }) + const txnFee = btxn.fee ?? 0n + const txnFeeDelta = FeeDelta.fromBigInt(minTxnFee - txnFee) + + if (btxn.type === TransactionType.AppCall) { + // Calculate inner transaction fee delta + const innerTxnsFeeDelta = calculateInnerFeeDelta(simulateTxnResult.txnResult.innerTxns, suggestedParams.minFee) + requiredFeeDelta = FeeDelta.fromBigInt( + (innerTxnsFeeDelta ? FeeDelta.toBigInt(innerTxnsFeeDelta) : 0n) + (txnFeeDelta ? FeeDelta.toBigInt(txnFeeDelta) : 0n), + ) + } else { + requiredFeeDelta = txnFeeDelta + } } - } - - return { transactions, methodCalls, signers } - } - - /** - * Get the number of transactions currently added to this composer. - * @returns The number of transactions currently added to this composer - */ - async count() { - return (await this.buildTransactions()).transactions.length - } - /** - * Compose all of the transactions in a single atomic transaction group and an atomic transaction composer. - * - * You can then use the transactions standalone, or use the composer to execute or simulate the transactions. - * - * Once this method is called, no further transactions will be able to be added. - * You can safely call this method multiple times to get the same result. - * @returns The built atomic transaction composer, the transactions and any corresponding method calls - * @example - * ```typescript - * const { atc, transactions, methodCalls } = await composer.build() - * ``` - */ - async build() { - if (this.atc.getStatus() === algosdk.AtomicTransactionComposerStatus.BUILDING) { - const suggestedParams = await this.getSuggestedParams() - // Build all of the transactions - const txnWithSigners: TransactionWithSignerAndContext[] = [] - for (const txn of this.txns) { - txnWithSigners.push(...(await this.buildTxnWithSigner(txn, suggestedParams))) + return { + requiredFeeDelta, + unnamedResourcesAccessed: analysisParams.populateAppCallResources ? simulateTxnResult.unnamedResourcesAccessed : undefined, } + }) - // Add all of the transactions to the underlying ATC - const methodCalls = new Map() - txnWithSigners.forEach(({ context, ...ts }, idx) => { - this.atc.addTransaction(ts) + const sortedResources = groupResponse.unnamedResourcesAccessed - // Populate consolidated set of all ABI method calls - if (context.abiMethod) { - methodCalls.set(idx, context.abiMethod) - } + // NOTE: We explicitly want to avoid localeCompare as that can lead to different results in different environments + const compare = (a: string | bigint, b: string | bigint) => (a < b ? -1 : a > b ? 1 : 0) - if (context.maxFee !== undefined) { - this.txnMaxFees.set(idx, context.maxFee) - } + if (sortedResources) { + sortedResources.accounts?.sort((a, b) => compare(a.toString(), b.toString())) + sortedResources.assets?.sort(compare) + sortedResources.apps?.sort(compare) + sortedResources.boxes?.sort((a, b) => { + const aStr = `${a.app}-${a.name}` + const bStr = `${b.app}-${b.name}` + return compare(aStr, bStr) + }) + sortedResources.appLocals?.sort((a, b) => { + const aStr = `${a.app}-${a.account}` + const bStr = `${b.app}-${b.account}` + return compare(aStr, bStr) + }) + sortedResources.assetHoldings?.sort((a, b) => { + const aStr = `${a.asset}-${a.account}` + const bStr = `${b.asset}-${b.account}` + return compare(aStr, bStr) }) - this.atc['methodCalls'] = methodCalls } - return { atc: this.atc, transactions: this.atc.buildGroup(), methodCalls: this.atc['methodCalls'] } + return { + transactions: txnAnalysisResults, + unnamedResourcesAccessed: analysisParams.populateAppCallResources ? sortedResources : undefined, + } } /** * Rebuild the group, discarding any previously built transactions. * This will potentially cause new signers and suggested params to be used if the callbacks return a new value compared to the first build. - * @returns The newly built atomic transaction composer and the transactions + * @returns The newly built transaction composer and the transactions * @example * ```typescript * const { atc, transactions, methodCalls } = await composer.rebuild() * ``` */ async rebuild() { - this.atc = new algosdk.AtomicTransactionComposer() + this.transactionsWithSigners = undefined return await this.build() } /** - * Compose the atomic transaction group and send it to the network. + * Compose the transaction group and send it to the network. * @param params The parameters to control execution with * @returns The execution result * @example @@ -2035,66 +1823,203 @@ export class TransactionComposer { * const result = await composer.send() * ``` */ - async send(params?: SendParams): Promise { - const group = (await this.build()).transactions - - let waitRounds = params?.maxRoundsToWaitForConfirmation - - const suggestedParams = - waitRounds === undefined || params?.coverAppCallInnerTransactionFees ? await this.getSuggestedParams() : undefined + async send(params?: SendParams): Promise { + if ( + this.composerConfig.coverAppCallInnerTransactionFees !== (params?.coverAppCallInnerTransactionFees ?? false) || + this.composerConfig.populateAppCallResources !== (params?.populateAppCallResources ?? true) + ) { + // If the params are different to the composer config, reset the builtGroup + // to ensure that the SendParams overwrites the composer config + this.composerConfig = { + coverAppCallInnerTransactionFees: params?.coverAppCallInnerTransactionFees ?? false, + populateAppCallResources: params?.populateAppCallResources ?? true, + } - if (waitRounds === undefined) { - const lastRound = group.reduce((max, txn) => (txn.txn.lastValid > max ? txn.txn.lastValid : BigInt(max)), 0n) - const { firstValid: firstRound } = suggestedParams! - waitRounds = Number(BigInt(lastRound) - BigInt(firstRound)) + 1 + this.transactionsWithSigners = undefined + this.signedTransactions = undefined } try { - return await sendAtomicTransactionComposer( - { - atc: this.atc, - suppressLog: params?.suppressLog, - maxRoundsToWaitForConfirmation: waitRounds, - populateAppCallResources: params?.populateAppCallResources, - coverAppCallInnerTransactionFees: params?.coverAppCallInnerTransactionFees, - additionalAtcContext: params?.coverAppCallInnerTransactionFees - ? { - maxFees: this.txnMaxFees, - suggestedParams: suggestedParams!, - } - : undefined, - }, - this.algod, - ) - } catch (originalError: unknown) { - throw await this.transformError(originalError) + await this.gatherSignatures() + + if ( + !this.transactionsWithSigners || + this.transactionsWithSigners.length === 0 || + !this.signedTransactions || + this.signedTransactions.length === 0 + ) { + throw new Error('No transactions available') + } + + const transactionsToSend = this.transactionsWithSigners.map((stxn) => stxn.txn) + const transactionIds = transactionsToSend.map((txn) => getTransactionId(txn)) + + if (transactionsToSend.length > 1) { + const groupId = transactionsToSend[0].group ? Buffer.from(transactionsToSend[0].group).toString('base64') : '' + Config.getLogger(params?.suppressLog).verbose(`Sending group of ${transactionsToSend.length} transactions (${groupId})`, { + transactionsToSend, + }) + + Config.getLogger(params?.suppressLog).debug(`Transaction IDs (${groupId})`, transactionIds) + } + + if (Config.debug && Config.traceAll) { + const simulateResult = await this.simulate({ + allowEmptySignatures: true, + fixSigners: true, + allowMoreLogging: true, + execTraceConfig: { + enable: true, + scratchChange: true, + stackChange: true, + stateChange: true, + }, + throwOnFailure: false, + }) + await Config.events.emitAsync(EventType.TxnGroupSimulated, { + simulateResponse: simulateResult, + }) + } + + const group = this.signedTransactions[0].txn.group + + let waitRounds = params?.maxRoundsToWaitForConfirmation + + if (waitRounds === undefined) { + const suggestedParams = await this.getSuggestedParams() + const firstRound = suggestedParams.firstValid + const lastRound = this.signedTransactions.reduce((max, txn) => (txn.txn.lastValid > max ? txn.txn.lastValid : max), 0n) + waitRounds = Number(lastRound - firstRound) + 1 + } + + const encodedTxns = encodeSignedTransactions(this.signedTransactions) + await this.algod.sendRawTransaction(encodedTxns) + + if (transactionsToSend.length > 1 && group) { + Config.getLogger(params?.suppressLog).verbose( + `Group transaction (${toBase64(group)}) sent with ${transactionsToSend.length} transactions`, + ) + } else { + Config.getLogger(params?.suppressLog).verbose( + `Sent transaction ID ${getTransactionId(transactionsToSend[0])} ${transactionsToSend[0].type} from ${transactionsToSend[0].sender}`, + ) + } + + let confirmations = new Array() + if (params?.maxRoundsToWaitForConfirmation !== 0) { + confirmations = await Promise.all(transactionIds.map(async (id) => await waitForConfirmation(id, waitRounds, this.algod))) + } + + const abiReturns = this.parseAbiReturnValues(confirmations, this.methodCalls) + + return { + groupId: group ? Buffer.from(group).toString('base64') : undefined, + transactions: transactionsToSend.map((t) => new TransactionWrapper(t)), + txIds: transactionIds, + returns: abiReturns, + confirmations: confirmations.map((c) => wrapPendingTransactionResponse(c)), + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (originalError: any) { + const errorMessage = originalError.body?.message ?? originalError.message ?? 'Received error executing Transaction Composer' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = new Error(errorMessage) as any + + // Don't include BuildComposerTransactionsError as cause because it can be noisy + // If the user needs to, they can enable the debug flag to get traces + err.cause = !(originalError instanceof BuildComposerTransactionsError) ? originalError : undefined + + if (typeof originalError === 'object') { + err.name = originalError.name + } + + const sentTransactions = + originalError instanceof BuildComposerTransactionsError + ? (originalError.sentTransactions ?? []) + : (this.transactionsWithSigners ?? []).map((transactionWithSigner) => transactionWithSigner.txn) + + if (Config.debug && typeof originalError === 'object') { + err.traces = [] + Config.getLogger(params?.suppressLog).error( + 'Received error executing Transaction Composer and debug flag enabled; attempting simulation to get more information', + err, + ) + + let simulateResponse: SimulateTransaction | undefined + if (originalError instanceof BuildComposerTransactionsError) { + simulateResponse = originalError.simulateResponse + } else { + const simulateResult = await this.simulate({ + allowEmptySignatures: true, + fixSigners: true, + allowMoreLogging: true, + execTraceConfig: { + enable: true, + scratchChange: true, + stackChange: true, + stateChange: true, + }, + throwOnFailure: false, + }) + simulateResponse = simulateResult.simulateResponse + } + + if (Config.debug && !Config.traceAll) { + // Emit the event only if traceAll: false, as it should have already been emitted above + await Config.events.emitAsync(EventType.TxnGroupSimulated, { + simulateResponse, + }) + } + + if (simulateResponse && simulateResponse.txnGroups[0].failedAt) { + for (const txn of simulateResponse.txnGroups[0].txnResults) { + err.traces.push({ + trace: AlgorandSerializer.encode(txn.execTrace, SimulationTransactionExecTraceMeta, 'map'), + appBudget: txn.appBudgetConsumed, + logicSigBudget: txn.logicSigBudgetConsumed, + logs: txn.txnResult.logs, + message: simulateResponse.txnGroups[0].failureMessage, + }) + } + } + } else { + Config.getLogger(params?.suppressLog).error( + 'Received error executing Transaction Composer, for more information enable the debug flag', + err, + ) + } + + // Attach the sent transactions so we can use them in error transformers + err.sentTransactions = sentTransactions.map((t) => new TransactionWrapper(t)) + + throw await this.transformError(err) } } /** * @deprecated Use `send` instead. * - * Compose the atomic transaction group and send it to the network + * Compose the transaction group and send it to the network * * An alias for `composer.send(params)`. * @param params The parameters to control execution with * @returns The execution result */ - async execute(params?: SendParams): Promise { + async execute(params?: SendParams): Promise { return this.send(params) } /** - * Compose the atomic transaction group and simulate sending it to the network + * Compose the transaction group and simulate sending it to the network * @returns The simulation result * @example * ```typescript * const result = await composer.simulate() * ``` */ - async simulate(): Promise + async simulate(): Promise /** - * Compose the atomic transaction group and simulate sending it to the network + * Compose the transaction group and simulate sending it to the network * @returns The simulation result * @example * ```typescript @@ -2105,9 +2030,9 @@ export class TransactionComposer { */ async simulate( options: SkipSignaturesSimulateOptions, - ): Promise + ): Promise /** - * Compose the atomic transaction group and simulate sending it to the network + * Compose the transaction group and simulate sending it to the network * @returns The simulation result * @example * ```typescript @@ -2116,28 +2041,46 @@ export class TransactionComposer { * }) * ``` */ - async simulate(options: RawSimulateOptions): Promise - async simulate(options?: SimulateOptions): Promise { - const { skipSignatures = false, ...rawOptions } = options ?? {} - const atc = skipSignatures ? new AtomicTransactionComposer() : this.atc + async simulate(options: RawSimulateOptions): Promise + async simulate(options?: SimulateOptions): Promise { + const { skipSignatures = false, throwOnFailure = true, ...rawOptions } = options ?? {} - // Build the transactions if (skipSignatures) { rawOptions.allowEmptySignatures = true rawOptions.fixSigners = true - // Build transactions uses empty signers - const transactions = await this.buildTransactions() - for (const txn of transactions.transactions) { - atc.addTransaction({ txn, signer: TransactionComposer.NULL_SIGNER }) - } - atc['methodCalls'] = transactions.methodCalls + } + + let transactionsWithSigner: TransactionWithSigner[] + if (!this.transactionsWithSigners) { + const suggestedParams = await this.getSuggestedParams() + const builtTransactions = await this._buildTransactions(suggestedParams) + const transactions = + builtTransactions.transactions.length > 0 ? groupTransactions(builtTransactions.transactions) : builtTransactions.transactions + + transactionsWithSigner = transactions.map((txn, index) => ({ + txn: txn, + signer: skipSignatures + ? algosdk.makeEmptyTransactionSigner() + : (builtTransactions.signers.get(index) ?? algosdk.makeEmptyTransactionSigner()), + })) } else { - // Build creates real signatures - await this.build() + rawOptions.allowUnnamedResources = true + + transactionsWithSigner = this.transactionsWithSigners.map((e) => ({ + txn: e.txn, + signer: skipSignatures ? algosdk.makeEmptyTransactionSigner() : e.signer, + })) } - const { methodResults, simulateResponse } = await atc.simulate(this.algod, { - txnGroups: [], + const transactions = transactionsWithSigner.map((e) => e.txn) + const signedTransactions = await this.signTransactions(transactionsWithSigner) + + const simulateRequest = { + txnGroups: [ + { + txns: signedTransactions, + }, + ], ...rawOptions, ...(Config.debug ? { @@ -2152,33 +2095,38 @@ export class TransactionComposer { }, } : undefined), - } satisfies SimulateRequest) + } satisfies SimulateRequest - const failedGroup = simulateResponse?.txnGroups[0] - if (failedGroup?.failureMessage) { - const errorMessage = `Transaction failed at transaction(s) ${failedGroup.failedAt?.join(', ') || 'unknown'} in the group. ${failedGroup.failureMessage}` + const simulateResponse = await this.algod.simulateTransaction(simulateRequest) + const simulateResult = simulateResponse.txnGroups[0] + + if (simulateResult?.failureMessage && throwOnFailure) { + const errorMessage = `Transaction failed at transaction(s) ${simulateResult.failedAt?.join(', ') || 'unknown'} in the group. ${simulateResult.failureMessage}` const error = new Error(errorMessage) if (Config.debug) { - await Config.events.emitAsync(EventType.TxnGroupSimulated, { simulateResponse }) + await Config.events.emitAsync(EventType.TxnGroupSimulated, { simulateTransaction: simulateResponse }) } throw await this.transformError(error) } if (Config.debug && Config.traceAll) { - await Config.events.emitAsync(EventType.TxnGroupSimulated, { simulateResponse }) + await Config.events.emitAsync(EventType.TxnGroupSimulated, { simulateTransaction: simulateResponse }) } - const transactions = atc.buildGroup().map((t) => t.txn) - const methodCalls = [...(atc['methodCalls'] as Map).values()] + const abiReturns = this.parseAbiReturnValues( + simulateResult.txnResults.map((t) => t.txnResult), + this.methodCalls, + ) + return { - confirmations: simulateResponse.txnGroups[0].txnResults.map((t) => wrapPendingTransactionResponse(t.txnResult)), + confirmations: simulateResult.txnResults.map((t) => wrapPendingTransactionResponse(t.txnResult)), transactions: transactions.map((t) => new TransactionWrapper(t)), txIds: transactions.map((t) => getTransactionId(t)), groupId: Buffer.from(transactions[0].group ?? new Uint8Array()).toString('base64'), simulateResponse, - returns: methodResults.map((r, i) => getABIReturnValue(r, methodCalls[i]!.returns.type)), + returns: abiReturns, } } @@ -2194,4 +2142,104 @@ export class TransactionComposer { const encoder = new TextEncoder() return encoder.encode(arc2Payload) } + + public async gatherSignatures(): Promise { + if (this.signedTransactions) { + return this.signedTransactions + } + + await this.build() + + if (!this.transactionsWithSigners || this.transactionsWithSigners.length === 0) { + throw new Error('No transactions available to sign') + } + + this.signedTransactions = await this.signTransactions(this.transactionsWithSigners) + return this.signedTransactions + } + + private async signTransactions(transactionsWithSigners: TransactionWithSigner[]): Promise { + if (transactionsWithSigners.length === 0) { + throw new Error('No transactions available to sign') + } + + const transactions = transactionsWithSigners.map((txnWithSigner) => txnWithSigner.txn) + + // Group transactions by signer + const signerGroups = new Map() + transactionsWithSigners.forEach(({ signer }, index) => { + const indexes = signerGroups.get(signer) ?? [] + indexes.push(index) + signerGroups.set(signer, indexes) + }) + + // Sign transactions in parallel for each signer + const signerEntries = Array.from(signerGroups) + const signedGroups = await Promise.all(signerEntries.map(([signer, indexes]) => signer(transactions, indexes))) + + // Reconstruct signed transactions in original order + const signedTransactions = new Array(transactionsWithSigners.length) + signerEntries.forEach(([, indexes], signerIndex) => { + const stxs = signedGroups[signerIndex] + indexes.forEach((txIndex, stxIndex) => { + signedTransactions[txIndex] = decodeSignedTransaction(stxs[stxIndex]) + }) + }) + + // Verify all transactions were signed + const unsignedIndexes = signedTransactions + .map((stxn, index) => (stxn === undefined ? index : null)) + .filter((index): index is number => index !== null) + + if (unsignedIndexes.length > 0) { + throw new Error(`Transactions at indexes [${unsignedIndexes.join(', ')}] were not signed`) + } + + return signedTransactions + } + + private parseAbiReturnValues(confirmations: PendingTransactionResponse[], methodCalls: Map): ABIReturn[] { + const abiReturns = new Array() + + for (let i = 0; i < confirmations.length; i++) { + const confirmation = confirmations[i] + const method = methodCalls.get(i) + + if (method && method.returns.type !== 'void') { + const abiReturn = AppManager.getABIReturn(confirmation, method) + if (abiReturn !== undefined) { + abiReturns.push(abiReturn) + } + } + } + + return abiReturns + } + + public setMaxFees(maxFees: Map) { + maxFees.forEach((_, index) => { + if (index > this.txns.length - 1) { + throw new Error(`Index ${index} is out of range. The composer only contains ${this.txns.length} transactions`) + } + }) + + maxFees.forEach((maxFee, index) => { + this.txns[index].data.maxFee = new AlgoAmount({ microAlgos: maxFee.microAlgos }) + }) + } +} + +/** Get the logical maximum fee based on staticFee and maxFee */ +function getLogicalMaxFee(ctxn: Txn): bigint | undefined { + if (ctxn.type === 'txn' || ctxn.type === 'asyncTxn') { + return undefined + } + + const maxFee = ctxn.data.maxFee + const staticFee = ctxn.data.staticFee + + if (maxFee !== undefined && (staticFee === undefined || maxFee.microAlgos > staticFee.microAlgos)) { + return maxFee.microAlgos + } + return staticFee?.microAlgos } diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 795d4fb3..4bba062a 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -1,4 +1,4 @@ -import { PendingTransactionResponse, SuggestedParams } from '@algorandfoundation/algokit-algod-client' +import { PendingTransactionResponse } from '@algorandfoundation/algokit-algod-client' import { AppCallTransactionFields, AssetConfigTransactionFields, @@ -13,10 +13,11 @@ import { } from '@algorandfoundation/algokit-transact' import { HeartbeatTransactionFields } from '@algorandfoundation/algokit-transact/transactions/heartbeat' import { StateProofTransactionFields } from '@algorandfoundation/algokit-transact/transactions/state-proof' -import { AtomicTransactionComposer, LogicSigAccount, type Account } from '@algorandfoundation/sdk' +import { LogicSigAccount, type Account } from '@algorandfoundation/sdk' import { MultisigAccount, SigningAccount, TransactionSignerAccount } from './account' import { AlgoAmount } from './amount' import { ABIReturn } from './app' +import { TransactionComposer } from './composer' import { Expand } from './expand' export type TransactionNote = Uint8Array | TransactionNoteData | Arc2TransactionNote @@ -42,8 +43,8 @@ export interface SendTransactionParams { skipSending?: boolean /** Whether to skip waiting for the submitted transaction (only relevant if `skipSending` is `false` or unset) */ skipWaiting?: boolean - /** An optional `AtomicTransactionComposer` to add the transaction to, if specified then `skipSending: undefined` has the same effect as `skipSending: true` */ - atc?: AtomicTransactionComposer + /** An optional `TransactionComposer` to add the transaction to, if specified then `skipSending: undefined` has the same effect as `skipSending: true` */ + transactionComposer?: TransactionComposer /** Whether to suppress log messages from transaction send, default: do not suppress */ suppressLog?: boolean /** The flat fee you want to pay, useful for covering extra fees in a transaction group or app call */ @@ -57,7 +58,7 @@ export interface SendTransactionParams { } /** Result from sending a single transaction. */ -export type SendSingleTransactionResult = Expand +export type SendSingleTransactionResult = Expand /** The result of sending a transaction */ export interface SendTransactionResult { @@ -77,10 +78,10 @@ export interface SendTransactionResults { confirmations?: PendingTransactionResponseWrapper[] } -/** The result of preparing and/or sending multiple transactions using an `AtomicTransactionComposer` */ -export interface SendAtomicTransactionComposerResults extends Omit { - /** base64 encoded representation of the group ID of the atomic group */ - groupId: string +/** The result of preparing and/or sending multiple transactions using an `TransactionComposer` */ +export interface SendTransactionComposerResults extends Omit { + /** base64 encoded representation of the group ID of the group */ + groupId: string | undefined /** The transaction IDs that have been prepared and/or sent */ txIds: string[] /** If ABI method(s) were called the processed return values */ @@ -125,7 +126,7 @@ export interface TransactionToSign { signer: SendTransactionFrom } -/** A group of transactions to send together as an atomic group +/** A group of transactions to send together as an group * https://dev.algorand.co/concepts/transactions/atomic-txn-groups/ */ export interface TransactionGroupToSend { @@ -152,29 +153,20 @@ export interface SendParams { coverAppCallInnerTransactionFees?: boolean } -/** Additional context about the `AtomicTransactionComposer`. */ -export interface AdditionalAtomicTransactionComposerContext { - /** A map of transaction index in the `AtomicTransactionComposer` to the max fee that can be calculated for a transaction in the group */ +/** Additional context about the `TransactionComposer`. */ +export interface AdditionalTransactionComposerContext { + /** A map of transaction index in the `TransactionComposer` to the max fee that can be calculated for a transaction in the group */ maxFees: Map - - /* The suggested params info relevant to transactions in the `AtomicTransactionComposer` */ - suggestedParams: Pick } -/** An `AtomicTransactionComposer` with transactions to send. */ -export interface AtomicTransactionComposerToSend extends SendParams { - /** The `AtomicTransactionComposer` with transactions loaded to send */ - atc: AtomicTransactionComposer +/** An `TransactionComposer` with transactions to send. */ +export interface TransactionComposerToSend extends SendParams { + /** The `TransactionComposer` with transactions loaded to send */ + transactionComposer: TransactionComposer /** * @deprecated - set the parameters at the top level instead * Any parameters to control the semantics of the send to the network */ sendParams?: Omit - - /** - * Additional `AtomicTransactionComposer` context used when building the transaction group that is sent. - * This additional context is used and must be supplied when coverAppCallInnerTransactionFees is set to true. - **/ - additionalAtcContext?: AdditionalAtomicTransactionComposerContext } export class TransactionWrapper implements Transaction { @@ -229,7 +221,6 @@ export class TransactionWrapper implements Transaction { } } -// TODO: PD - review the names of these wrapper export type SignedTransactionWrapper = Omit & { txn: TransactionWrapper } diff --git a/tests/example-contracts/client/TestContractClient.ts b/tests/example-contracts/client/TestContractClient.ts index 7235784a..a98dbafb 100644 --- a/tests/example-contracts/client/TestContractClient.ts +++ b/tests/example-contracts/client/TestContractClient.ts @@ -4,11 +4,11 @@ * DO NOT MODIFY IT BY HAND. * requires: @algorandfoundation/algokit-utils: ^2 */ -import { AlgodClient, SimulateTransactionGroupResult, SimulateTransactionResult } from '@algorandfoundation/algokit-algod-client' +import { AlgodClient, SimulateRequest, SimulateTransactionGroupResult } from '@algorandfoundation/algokit-algod-client' import { OnApplicationComplete, Transaction } from '@algorandfoundation/algokit-transact' -import type { ABIResult, TransactionWithSigner } from '@algorandfoundation/sdk' -import { AtomicTransactionComposer } from '@algorandfoundation/sdk' +import type { ABIResult } from '@algorandfoundation/sdk' import * as algokit from '../../../src/index' +import { TransactionWithSigner } from '../../../src/index' import type { ABIAppCallArg, AppCallTransactionResult, @@ -27,6 +27,7 @@ import type { ApplicationClient, } from '../../../src/types/app-client' import type { AppSpec } from '../../../src/types/app-spec' +import { TransactionComposer } from '../../../src/types/composer' import type { SendTransactionFrom, SendTransactionParams, SendTransactionResult, TransactionToSign } from '../../../src/types/transaction' export const APP_SPEC: AppSpec = { hints: { @@ -722,41 +723,46 @@ export class TestContractClient { public compose(): TestContractComposer { const client = this - const atc = new AtomicTransactionComposer() + const transactionComposer = new TransactionComposer({ + algod: this.algod, + getSigner: (address) => { + throw new Error(`No signer for address ${address}`) + }, + }) let promiseChain: Promise = Promise.resolve() const resultMappers: Array any)> = [] return { doMath(args: MethodArgs<'doMath(uint64,uint64,string)uint64'>, params?: AppClientComposeCallCoreParams & CoreAppCallArgs) { promiseChain = promiseChain.then(() => - client.doMath(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, atc } }), + client.doMath(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, transactionComposer } }), ) resultMappers.push(undefined) return this }, txnArg(args: MethodArgs<'txnArg(pay)address'>, params?: AppClientComposeCallCoreParams & CoreAppCallArgs) { promiseChain = promiseChain.then(() => - client.txnArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, atc } }), + client.txnArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, transactionComposer } }), ) resultMappers.push(undefined) return this }, helloWorld(args: MethodArgs<'helloWorld()string'>, params?: AppClientComposeCallCoreParams & CoreAppCallArgs) { promiseChain = promiseChain.then(() => - client.helloWorld(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, atc } }), + client.helloWorld(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, transactionComposer } }), ) resultMappers.push(undefined) return this }, methodArg(args: MethodArgs<'methodArg(appl)uint64'>, params?: AppClientComposeCallCoreParams & CoreAppCallArgs) { promiseChain = promiseChain.then(() => - client.methodArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, atc } }), + client.methodArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, transactionComposer } }), ) resultMappers.push(undefined) return this }, nestedTxnArg(args: MethodArgs<'nestedTxnArg(pay,appl)uint64'>, params?: AppClientComposeCallCoreParams & CoreAppCallArgs) { promiseChain = promiseChain.then(() => - client.nestedTxnArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, atc } }), + client.nestedTxnArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, transactionComposer } }), ) resultMappers.push(undefined) return this @@ -766,13 +772,15 @@ export class TestContractClient { params?: AppClientComposeCallCoreParams & CoreAppCallArgs, ) { promiseChain = promiseChain.then(() => - client.doubleNestedTxnArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, atc } }), + client.doubleNestedTxnArg(args, { ...params, sendParams: { ...params?.sendParams, skipSending: true, transactionComposer } }), ) resultMappers.push(undefined) return this }, clearState(args?: BareCallArgs & AppClientComposeCallCoreParams & CoreAppCallArgs) { - promiseChain = promiseChain.then(() => client.clearState({ ...args, sendParams: { ...args?.sendParams, skipSending: true, atc } })) + promiseChain = promiseChain.then(() => + client.clearState({ ...args, sendParams: { ...args?.sendParams, skipSending: true, transactionComposer } }), + ) resultMappers.push(undefined) return this }, @@ -780,28 +788,27 @@ export class TestContractClient { txn: TransactionWithSigner | TransactionToSign | Transaction | Promise, defaultSender?: SendTransactionFrom, ) { - promiseChain = promiseChain.then(async () => - atc.addTransaction(await algokit.getTransactionWithSigner(txn, defaultSender ?? client.sender)), - ) + promiseChain = promiseChain.then(async () => { + const txnWithSigner = await algokit.getTransactionWithSigner(txn, defaultSender ?? client.sender) + transactionComposer.addTransaction(txnWithSigner.txn, txnWithSigner.signer) + }) return this }, - async atc() { + async transactionComposer() { await promiseChain - return atc + return transactionComposer }, async simulate(options?: SimulateOptions) { await promiseChain - const result = await atc.simulate(client.algod, { txnGroups: [], ...options }) + const result = options ? await transactionComposer.simulate(options) : await transactionComposer.simulate() return { ...result, - returns: result.methodResults?.map((val, i) => - resultMappers[i] !== undefined ? resultMappers[i]!(val.returnValue) : val.returnValue, - ), + returns: result.returns?.map((val, i) => (resultMappers[i] !== undefined ? resultMappers[i]!(val.returnValue) : val.returnValue)), } }, async execute(sendParams?: AppClientComposeExecuteParams) { await promiseChain - const result = await algokit.sendAtomicTransactionComposer({ atc, sendParams }, client.algod) + const result = await algokit.sendTransactionComposer({ transactionComposer, sendParams }) return { ...result, returns: result.returns?.map((val, i) => (resultMappers[i] !== undefined ? resultMappers[i]!(val.returnValue) : val.returnValue)), @@ -904,9 +911,9 @@ export type TestContractComposer = { defaultSender?: SendTransactionFrom, ): TestContractComposer /** - * Returns the underlying AtomicTransactionComposer instance + * Returns the underlying TransactionComposer instance */ - atc(): Promise + transactionComposer(): Promise /** * Simulates the transaction group and returns the result */ @@ -916,7 +923,7 @@ export type TestContractComposer = { */ execute(sendParams?: AppClientComposeExecuteParams): Promise> } -export type SimulateOptions = Omit +export type SimulateOptions = Omit export type TestContractComposerSimulateResult = { returns: TReturns methodResults: ABIResult[] diff --git a/tsconfig.json b/tsconfig.json index 10aeb1a6..b1c6d93e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,6 @@ "importHelpers": true, "isolatedModules": true, "resolveJsonModule": true, - "baseUrl": ".", "paths": { "@algorandfoundation/algokit-common": ["./packages/common/src"], "@algorandfoundation/algokit-common/*": ["./packages/common/src/*"],