diff --git a/package-lock.json b/package-lock.json index 9e498790..8cea6b55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@algorand/algod-client": "file:../algokit-core/api/api_clients/typescript/algorand-algod-client-0.0.1.tgz", "@algorandfoundation/tealscript": "^0.106.3", + "algokit_transact": "file:../algokit-core/algokit_transact-0.1.0.tgz", "buffer": "^6.0.3" }, "devDependencies": { @@ -65,6 +67,15 @@ "node": ">=0.10.0" } }, + "node_modules/@algorand/algod-client": { + "version": "0.0.1", + "resolved": "file:../algokit-core/api/api_clients/typescript/algorand-algod-client-0.0.1.tgz", + "integrity": "sha512-+cdvDs1JvpPHH5ELO4GRn7skIhSmJRmTyzpoDrRMYfdlrcMd6hKVijoCF/9+cLlz30aJ+9MzQhQhMldVkP4lLg==", + "dependencies": { + "es6-promise": "^4.2.4", + "whatwg-fetch": "^3.0.0" + } + }, "node_modules/@algorandfoundation/tealscript": { "version": "0.106.3", "resolved": "https://registry.npmjs.org/@algorandfoundation/tealscript/-/tealscript-0.106.3.tgz", @@ -3124,6 +3135,11 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/algokit_transact": { + "version": "0.1.0", + "resolved": "file:../algokit-core/algokit_transact-0.1.0.tgz", + "integrity": "sha512-Il4DEzeuKcBAJWGl6j6i4RhCDk0+9A49XCxX/EdfK007JO+qWZ4QPK9fgWsjr6wNhPZE+7+k9oVcmep7ADhZGg==" + }, "node_modules/algorand-msgpack": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/algorand-msgpack/-/algorand-msgpack-1.1.0.tgz", @@ -4576,6 +4592,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -12468,6 +12489,11 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index d3ae73ba..52328803 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,9 @@ "esbuild": "0.25.0" }, "dependencies": { + "@algorand/algod-client": "file:../algokit-core/api/api_clients/typescript/algorand-algod-client-0.0.1.tgz", "@algorandfoundation/tealscript": "^0.106.3", + "algokit_transact": "file:../algokit-core/algokit_transact-0.1.0.tgz", "buffer": "^6.0.3" }, "peerDependencies": { @@ -171,4 +173,4 @@ "@semantic-release/github" ] } -} \ No newline at end of file +} diff --git a/src/transaction/transaction.spec.ts b/src/transaction/transaction.spec.ts index f17eaf6f..8e385921 100644 --- a/src/transaction/transaction.spec.ts +++ b/src/transaction/transaction.spec.ts @@ -1,16 +1,18 @@ import algosdk, { ABIMethod, ABIType, Account, Address } from 'algosdk' import invariant from 'tiny-invariant' -import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { APP_SPEC as nestedContractAppSpec } from '../../tests/example-contracts/client/TestContractClient' import innerFeeContract from '../../tests/example-contracts/inner-fee/application.json' import externalARC32 from '../../tests/example-contracts/resource-packer/artifacts/ExternalApp.arc32.json' import v8ARC32 from '../../tests/example-contracts/resource-packer/artifacts/ResourcePackerv8.arc32.json' import v9ARC32 from '../../tests/example-contracts/resource-packer/artifacts/ResourcePackerv9.arc32.json' -import { algo, microAlgo } from '../amount' +import { algo, algos, microAlgo } from '../amount' import { Config } from '../config' -import { algorandFixture } from '../testing' +import { algorandFixture, getTestAccount } from '../testing' +import { AlgorandClient } from '../types/algorand-client' import { AlgoAmount } from '../types/amount' import { AppClient } from '../types/app-client' +import { ClientManager } from '../types/client-manager' import { PaymentParams, TransactionComposer } from '../types/composer' import { Arc2TransactionNote } from '../types/transaction' import { getABIReturnValue, waitForConfirmation } from './transaction' @@ -1157,3 +1159,37 @@ describe('abi return', () => { ]) }) }) + +describe('When create algorand client with config from environment', () => { + test('payment transactions are sent and waited for by algokit core algod client', async () => { + const algorandClient = AlgorandClient.fromConfig(ClientManager.getConfigFromEnvironmentOrLocalNet()) + + const sendRawTransactionWithAlgoKitCoreAlgod = vi.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (algorandClient.client.algod.c as any).bc._algoKitCoreAlgod, + 'rawTransactionResponse', + ) + + const sendPendingTransactionInformationResponseWithAlgoKitCoreAlgod = vi.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (algorandClient.client.algod.c as any).bc._algoKitCoreAlgod, + 'pendingTransactionInformationResponse', + ) + + const testAccount = await getTestAccount({ initialFunds: algos(10), suppressLog: true }, algorandClient) + algorandClient.setSignerFromAccount(testAccount) + + const testPayTransaction = { + sender: testAccount, + receiver: testAccount, + amount: (1).microAlgo(), + } as PaymentParams + + const fee = (1).algo() + const { confirmation } = await algorandClient.send.payment({ ...testPayTransaction, staticFee: fee }) + + expect(sendRawTransactionWithAlgoKitCoreAlgod).toBeCalledTimes(2) + expect(sendPendingTransactionInformationResponseWithAlgoKitCoreAlgod).toBeCalled() + expect(confirmation.txn.txn.fee).toBe(fee.microAlgo) + }) +}) diff --git a/src/types/algo-http-client-with-retry.ts b/src/types/algo-http-client-with-retry.ts index 214e194d..3db4572f 100644 --- a/src/types/algo-http-client-with-retry.ts +++ b/src/types/algo-http-client-with-retry.ts @@ -1,9 +1,29 @@ -import { IntDecoding, parseJSON, stringifyJSON } from 'algosdk' +import { AlgodApi } from '@algorand/algod-client' +import { + BaseHTTPClientError, + decodeSignedTransaction, + IntDecoding, + parseJSON, + SignedTransaction, + stringifyJSON, + TokenHeader, + TransactionType, +} from 'algosdk' import { BaseHTTPClientResponse, Query, URLTokenBaseHTTPClient } from 'algosdk/client' import { Config } from '../config' +import { buildAlgoKitCoreAlgodClient } from './algokit-core-bridge' /** A HTTP Client that wraps the Algorand SDK HTTP Client with retries */ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { + private _algoKitCoreAlgod: AlgodApi + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(tokenHeader: TokenHeader, baseServer: string, port?: string | number, defaultHeaders?: Record) { + super(tokenHeader, baseServer, port, defaultHeaders) + + this._algoKitCoreAlgod = buildAlgoKitCoreAlgodClient(this.buildBaseServerUrl(baseServer, port), tokenHeader) + } + private static readonly MAX_TRIES = 5 private static readonly MAX_BACKOFF_MS = 10000 @@ -22,6 +42,21 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { 'EPROTO', // We get this intermittently with AlgoNode API ] + private buildBaseServerUrl(baseServer: string, port?: string | number) { + // This logic is copied from algosdk to make sure that we have the same base server config + + // Append a trailing slash so we can use relative paths. Without the trailing + // slash, the last path segment will be replaced by the relative path. See + // usage in `addressWithPath`. + const fixedBaseServer = baseServer.endsWith('/') ? baseServer : `${baseServer}/` + const baseServerURL = new URL(fixedBaseServer) + if (typeof port !== 'undefined') { + baseServerURL.port = port.toString() + } + + return baseServerURL + } + private async callWithRetry(func: () => Promise): Promise { let response: BaseHTTPClientResponse | undefined let numTries = 1 @@ -33,6 +68,7 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { if (numTries >= AlgoHttpClientWithRetry.MAX_TRIES) { throw err } + // Only retry for one of the hardcoded conditions if ( !( @@ -55,6 +91,24 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { } async get(relativePath: string, query?: Query, requestHeaders: Record = {}): Promise { + if (relativePath.startsWith('/v2/transactions/pending/')) { + const possibleTxnId = relativePath.replace('/v2/transactions/pending/', '').replace(/\/+$/, '') + // TODO: test for possibleTxnId + if (possibleTxnId) { + return await this.callWithRetry(async () => { + const httpInfo = await this._algoKitCoreAlgod.pendingTransactionInformationResponse(possibleTxnId, 'msgpack') + const binary = await httpInfo.body.binary() + const arrayBuffer = await binary.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + return { + status: httpInfo.httpStatusCode, + headers: httpInfo.headers, + body: uint8Array, + } + }) + } + } + const response = await this.callWithRetry(() => super.get(relativePath, query, requestHeaders)) if ( relativePath.startsWith('/v2/accounts/') && @@ -94,6 +148,58 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { query?: Query, requestHeaders: Record = {}, ): Promise { + if (relativePath.startsWith('/v2/transactions')) { + let signedTxn: SignedTransaction | undefined = undefined + try { + // Try to decode the data into a single transaction + // This will fail when sending a transaction group, in that case, we will ignore the error + signedTxn = decodeSignedTransaction(data) + } catch { + // Ignore errors here + } + if (signedTxn && signedTxn.txn.type === TransactionType.pay) { + return await this.callWithRetry(async () => { + const responseContext = await this._algoKitCoreAlgod.rawTransactionResponse(new File([data], '')) + + const binary = await responseContext.body.binary() + const arrayBuffer = await binary.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + + if (responseContext.httpStatusCode !== 200) { + // This logic is copied from algosdk to make sure that we produce the same errors + let bodyErrorMessage: string | undefined + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const decoded: Record = JSON.parse(new TextDecoder().decode(uint8Array)) + if (decoded.message) { + bodyErrorMessage = decoded.message + } + } catch { + // ignore any error that happened while we are parsing the error response + } + + let message = `Network request error. Received status ${responseContext.httpStatusCode} (${responseContext.httpStatusText})` + if (bodyErrorMessage) { + message += `: ${bodyErrorMessage}` + } + + throw new URLTokenBaseHTTPError(message, { + body: uint8Array, + status: responseContext.httpStatusCode, + headers: responseContext.headers, + }) + } + + return { + status: responseContext.httpStatusCode, + statusText: responseContext.httpStatusText, + headers: responseContext.headers, + body: uint8Array, + } + }) + } + } return await this.callWithRetry(() => super.post(relativePath, data, query, requestHeaders)) } @@ -106,3 +212,15 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { return await this.callWithRetry(() => super.delete(relativePath, data, query, requestHeaders)) } } + +// This is a copy of URLTokenBaseHTTPError from algosdk +class URLTokenBaseHTTPError extends Error implements BaseHTTPClientError { + constructor( + message: string, + public response: BaseHTTPClientResponse, + ) { + super(message) + this.name = 'URLTokenBaseHTTPError' + this.response = response + } +} diff --git a/src/types/algokit-core-bridge.ts b/src/types/algokit-core-bridge.ts new file mode 100644 index 00000000..30da438c --- /dev/null +++ b/src/types/algokit-core-bridge.ts @@ -0,0 +1,99 @@ +import * as algodApi from '@algorand/algod-client' +import { addressFromString, Transaction as AlgokitCoreTransaction, encodeTransactionRaw } from 'algokit_transact' +import algosdk, { Address, TokenHeader } from 'algosdk' + +function getAlgokitCoreAddress(address: string | Address) { + return addressFromString(typeof address === 'string' ? address : address.toString()) +} + +// Experimental feature to build algosdk payment transactions with algokit-core +export function buildPayment({ + sender, + receiver, + amount, + closeRemainderTo, + rekeyTo, + note, + lease, + suggestedParams, +}: algosdk.PaymentTransactionParams & algosdk.CommonTransactionParams) { + const txnModel: AlgokitCoreTransaction = { + header: { + sender: getAlgokitCoreAddress(sender), + transactionType: 'Payment', + fee: BigInt(suggestedParams.fee), + firstValid: BigInt(suggestedParams.firstValid), + lastValid: BigInt(suggestedParams.lastValid), + genesisHash: suggestedParams.genesisHash, + genesisId: suggestedParams.genesisID, + rekeyTo: rekeyTo ? getAlgokitCoreAddress(rekeyTo) : undefined, + note: note, + lease: lease, + }, + payFields: { + amount: BigInt(amount), + receiver: getAlgokitCoreAddress(receiver), + closeRemainderTo: closeRemainderTo ? getAlgokitCoreAddress(closeRemainderTo) : undefined, + }, + } + + let fee = BigInt(suggestedParams.fee) + if (!suggestedParams.flatFee) { + const minFee = BigInt(suggestedParams.minFee) + const numAddlBytesAfterSigning = 75 + const estimateTxnSize = encodeTransactionRaw(txnModel).length + numAddlBytesAfterSigning + + fee *= BigInt(estimateTxnSize) + // If suggested fee too small and will be rejected, set to min tx fee + if (fee < minFee) { + fee = minFee + } + } + txnModel.header.fee = fee + + return algosdk.decodeUnsignedTransaction(encodeTransactionRaw(txnModel)) +} + +export class TokenHeaderAuthenticationMethod implements algodApi.SecurityAuthentication { + private _header: string + private _key: string + + public constructor(tokenHeader: TokenHeader) { + if (Object.entries(tokenHeader).length === 0) { + throw new Error('Cannot construct empty token header auth') + } + + const [header, key] = Object.entries(tokenHeader)[0] + this._header = header + this._key = key + } + + public getName(): string { + return 'custom_header' + } + + public applySecurityAuthentication(context: algodApi.RequestContext) { + context.setHeaderParam(this._header, this._key) + } +} + +export function buildAlgoKitCoreAlgodClient(baseUrl: URL, tokenHeader: TokenHeader): algodApi.AlgodApi { + const authMethodConfig = Object.entries(tokenHeader).length > 0 ? new TokenHeaderAuthenticationMethod(tokenHeader) : undefined + const authConfig: algodApi.AuthMethodsConfiguration = { + default: authMethodConfig, + } + + // Create configuration parameter object + const fixedBaseUrl = baseUrl.toString().replace(/\/+$/, '') + const serverConfig = new algodApi.ServerConfiguration(fixedBaseUrl, {}) + const configurationParameters = { + httpApi: new algodApi.IsomorphicFetchHttpLibrary(), + baseServer: serverConfig, + authMethods: authConfig, + promiseMiddleware: [], + } + + // Convert to actual configuration + const config = algodApi.createConfiguration(configurationParameters) + return new algodApi.AlgodApi(config) +} diff --git a/src/types/composer.ts b/src/types/composer.ts index ce2e0dd4..bd686bd7 100644 --- a/src/types/composer.ts +++ b/src/types/composer.ts @@ -3,6 +3,7 @@ import { Config } from '../config' import { encodeLease, getABIReturnValue, sendAtomicTransactionComposer } from '../transaction/transaction' import { asJson, calculateExtraProgramPages } from '../util' import { TransactionSignerAccount } from './account' +import { buildPayment as buildPaymentWithAlgoKitCore } from './algokit-core-bridge' import { AlgoAmount } from './amount' import { AppManager, BoxIdentifier, BoxReference } from './app-manager' import { Expand } from './expand' @@ -1622,7 +1623,7 @@ export class TransactionComposer { } private buildPayment(params: PaymentParams, suggestedParams: algosdk.SuggestedParams) { - return this.commonTxnBuildStep(algosdk.makePaymentTxnWithSuggestedParamsFromObject, params, { + return this.commonTxnBuildStep(buildPaymentWithAlgoKitCore, params, { sender: params.sender, receiver: params.receiver, amount: params.amount.microAlgo,