Skip to content

Send and wait payment transactions with algokit core #393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -171,4 +173,4 @@
"@semantic-release/github"
]
}
}
}
42 changes: 39 additions & 3 deletions src/transaction/transaction.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
})
})
120 changes: 119 additions & 1 deletion src/types/algo-http-client-with-retry.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) {
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

Expand All @@ -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<BaseHTTPClientResponse>): Promise<BaseHTTPClientResponse> {
let response: BaseHTTPClientResponse | undefined
let numTries = 1
Expand All @@ -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 (
!(
Expand All @@ -55,6 +91,24 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient {
}

async get(relativePath: string, query?: Query<string>, requestHeaders: Record<string, string> = {}): Promise<BaseHTTPClientResponse> {
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/') &&
Expand Down Expand Up @@ -94,6 +148,58 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient {
query?: Query<string>,
requestHeaders: Record<string, string> = {},
): Promise<BaseHTTPClientResponse> {
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<string, any> = 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))
}

Expand All @@ -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
}
}
99 changes: 99 additions & 0 deletions src/types/algokit-core-bridge.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading