From 276311082dcfff6a686b1de6a160f3e5c68ef583 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 10 Oct 2025 15:41:29 +0200 Subject: [PATCH 01/18] chore: wip network, client, asset manager tweaks --- .../algokit_utils/src/clients/app-manager.ts | 49 ++- .../src/clients/asset-manager.ts | 330 ++++++++-------- .../src/clients/client-manager.ts | 371 ++++++++++++++++++ .../src/clients/network-client.ts | 42 +- .../typescript/algokit_utils/src/index.ts | 4 + 5 files changed, 618 insertions(+), 178 deletions(-) create mode 100644 packages/typescript/algokit_utils/src/clients/client-manager.ts diff --git a/packages/typescript/algokit_utils/src/clients/app-manager.ts b/packages/typescript/algokit_utils/src/clients/app-manager.ts index bd44206ef..d5cd48c63 100644 --- a/packages/typescript/algokit_utils/src/clients/app-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/app-manager.ts @@ -139,9 +139,8 @@ export class AppManager { return { appId, appAddress: getAppAddress(appId), - // TODO: this conversion from base64 encoded string to uint8array may happen inside the algod client - approvalProgram: new Uint8Array(Buffer.from(app.params.approvalProgram, 'base64')), - clearStateProgram: new Uint8Array(Buffer.from(app.params.clearStateProgram, 'base64')), + approvalProgram: AppManager.toBytes(app.params.approvalProgram), + clearStateProgram: AppManager.toBytes(app.params.clearStateProgram), creator: app.params.creator, localInts: Number(app.params.localStateSchema?.numUint ?? 0), localByteSlices: Number(app.params.localStateSchema?.numByteSlice ?? 0), @@ -170,10 +169,11 @@ export class AppManager { async getBoxNames(appId: bigint): Promise { const boxResult = await this.algodClient.getApplicationBoxes(appId) return boxResult.boxes.map((b) => { + const nameRaw = new Uint8Array(b.name) return { - nameRaw: new Uint8Array(Buffer.from(b.name)), - nameBase64: b.name, - name: Buffer.from(b.name).toString('utf-8'), + nameRaw, + nameBase64: AppManager.bytesToBase64(nameRaw), + name: AppManager.bytesToUtf8(nameRaw), } }) } @@ -182,12 +182,12 @@ export class AppManager { // Algod expects goal-arg style encoding for box name query param in 'encoding:value'. // However our HTTP client decodes base64 automatically into bytes for the Box model fields. // The API still requires 'b64:' for the query parameter value. - const processedBoxName = `b64:${Buffer.from(boxName).toString('base64')}` + const processedBoxName = `b64:${AppManager.bytesToBase64(boxName)}` const boxResult = await this.algodClient.getApplicationBoxByName(appId, { name: processedBoxName, }) - return new Uint8Array(Buffer.from(boxResult.value)) + return new Uint8Array(boxResult.value) } async getBoxValues(appId: bigint, boxNames: Uint8Array[]): Promise { @@ -200,14 +200,15 @@ export class AppManager { private static ensureDecodedBytes(bytes: Uint8Array): Uint8Array { try { - const str = Buffer.from(bytes).toString('utf8') + const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const str = buffer.toString('utf8') if ( str.length > 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(str) && (str.includes('=') || str.includes('+') || str.includes('/') || (str.length % 4 === 0 && str.length >= 8)) ) { const decoded = Buffer.from(str, 'base64') - if (!decoded.equals(Buffer.from(bytes))) { + if (!decoded.equals(buffer)) { return new Uint8Array(decoded) } } @@ -221,19 +222,19 @@ export class AppManager { const stateValues: Record = {} for (const stateVal of state) { - const keyRaw = new Uint8Array(Buffer.from(stateVal.key, 'base64')) + const keyRaw = AppManager.toBytes(stateVal.key) const keyBase64 = stateVal.key - const keyString = Buffer.from(keyRaw).toString('base64') + const keyString = keyBase64 // TODO: we will need to update the algod client to return int here if (stateVal.value.type === 1n) { - const valueRaw = AppManager.ensureDecodedBytes(new Uint8Array(Buffer.from(stateVal.value.bytes, 'base64'))) - const valueBase64 = Buffer.from(valueRaw).toString('base64') + const valueRaw = AppManager.ensureDecodedBytes(new Uint8Array(stateVal.value.bytes)) + const valueBase64 = AppManager.bytesToBase64(valueRaw) let valueStr: string try { - valueStr = Buffer.from(valueRaw).toString('utf8') + valueStr = new TextDecoder('utf-8', { fatal: true }).decode(valueRaw) } catch { - valueStr = Buffer.from(valueRaw).toString('hex') + valueStr = Buffer.from(valueRaw.buffer, valueRaw.byteOffset, valueRaw.byteLength).toString('hex') } const bytesState: BytesAppState = { @@ -345,6 +346,22 @@ export class AppManager { return /[a-zA-Z0-9_]/.test(ch) } + private static toBytes(value: Uint8Array | string): Uint8Array { + if (typeof value === 'string') { + return Uint8Array.from(Buffer.from(value, 'base64')) + } + + return new Uint8Array(value) + } + + private static bytesToBase64(value: Uint8Array): string { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64') + } + + private static bytesToUtf8(value: Uint8Array): string { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('utf-8') + } + static replaceTealTemplateDeployTimeControlParams(tealTemplateCode: string, params: DeploymentMetadata): string { let result = tealTemplateCode diff --git a/packages/typescript/algokit_utils/src/clients/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index f5f95a80e..038c32161 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -1,7 +1,6 @@ -import { type AccountAssetInformation, AlgodClient } from '@algorandfoundation/algod-client' +import { AccountAssetInformation, AlgodClient, ApiError } from '@algorandfoundation/algod-client' import { AssetOptInParams, AssetOptOutParams } from '../transactions/asset-transfer' import { TransactionComposer } from '../transactions/composer' -import { Buffer } from 'buffer' /** Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets. */ export interface BulkAssetOptInOutResult { @@ -20,162 +19,134 @@ export interface AssetInformation { /** The ID of the asset. */ assetId: bigint - /** The address of the account that created the asset. - * - * This is the address where the parameters for this asset can be found, - * and also the address where unwanted asset units can be sent when - * closing out an asset position and opting-out of the asset. - */ + /** The address of the account that created the asset. */ creator: string - /** The total amount of the smallest divisible (decimal) units that were created of the asset. - * - * For example, if `decimals` is, say, 2, then for every 100 `total` there is 1 whole unit. - */ + /** The total amount of the smallest divisible (decimal) units that were created of the asset. */ total: bigint - /** The amount of decimal places the asset was created with. - * - * * 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. - */ + /** The amount of decimal places the asset was created with. */ decimals: number - /** Whether the asset was frozen by default for all accounts. - * - * 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. - */ + /** Whether the asset was frozen by default for all accounts. */ defaultFrozen?: boolean - /** The address of the optional account that can manage the configuration of the asset and destroy it. - * - * If not set the asset is permanently immutable. - */ + /** The address of the optional account that can manage the configuration of the asset and destroy it. */ manager?: string - /** 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 the field is permanently empty. - */ + /** The address of the optional account that holds the reserve (uncirculated supply) units of the asset. */ reserve?: string - /** 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 the field is permanently empty. - */ + /** The address of the optional account that can be used to freeze or unfreeze holdings of this asset for any account. */ freeze?: string - /** The address of the optional account that can clawback holdings of this asset from any account. - * - * The clawback account has the ability to **unconditionally take assets from any account**. - * - * If empty, clawback is not permitted. - * - * If not set the field is permanently empty. - */ + /** The address of the optional account that can clawback holdings of this asset from any account. */ clawback?: string - /** The optional name of the unit of this asset (e.g. ticker name). - * - * Max size is 8 bytes. - */ + /** The optional name of the unit of this asset (e.g. ticker name). */ unitName?: string - /** The optional name of the unit of this asset as bytes. - * - * Max size is 8 bytes. - */ + /** The optional name of the unit of this asset as bytes. */ unitNameB64?: Uint8Array - /** The optional name of the asset. - * - * Max size is 32 bytes. - */ + /** The optional name of the asset. */ assetName?: string - /** The optional name of the asset as bytes. - * - * Max size is 32 bytes. - */ + /** The optional name of the asset as bytes. */ assetNameB64?: Uint8Array - /** Optional URL where more information about the asset can be retrieved (e.g. metadata). - * - * Max size is 96 bytes. - */ + /** Optional URL where more information about the asset can be retrieved (e.g. metadata). */ url?: string - /** Optional URL where more information about the asset can be retrieved as bytes. - * - * Max size is 96 bytes. - */ + /** Optional URL where more information about the asset can be retrieved as bytes. */ urlB64?: Uint8Array - /** 32-byte hash of some metadata that is relevant to the asset and/or asset holders. - * - * The format of this metadata is up to the application. - */ + /** 32-byte hash of some metadata that is relevant to the asset and/or asset holders. */ metadataHash?: Uint8Array } +export type AssetManagerErrorCode = + | 'ALGOD_CLIENT_ERROR' + | 'COMPOSER_ERROR' + | 'ASSET_NOT_FOUND' + | 'ACCOUNT_NOT_FOUND' + | 'NOT_OPTED_IN' + | 'NON_ZERO_BALANCE' + +export class AssetManagerError extends Error { + readonly code: AssetManagerErrorCode + readonly details?: Record + + constructor(code: AssetManagerErrorCode, message: string, details?: Record, cause?: unknown) { + super(message) + this.name = 'AssetManagerError' + this.code = code + this.details = details + if (cause !== undefined) { + ;(this as { cause?: unknown }).cause = cause + } + } +} + /** Manages Algorand Standard Assets. */ export class AssetManager { - private algodClient: AlgodClient - private newComposer: () => TransactionComposer + private readonly algodClient: AlgodClient + private readonly newComposer: () => TransactionComposer constructor(algodClient: AlgodClient, newComposer: () => TransactionComposer) { this.algodClient = algodClient this.newComposer = newComposer } - /** Get asset information by asset ID - * Returns a convenient, flattened view of the asset information. - */ + /** Get asset information by asset ID. Returns a convenient, flattened view of the asset information. */ async getById(assetId: bigint): Promise { - const asset = await this.algodClient.getAssetById(Number(assetId)) - - return { - assetId: asset.index, - creator: asset.params.creator, - total: asset.params.total, - decimals: Number(asset.params.decimals), // TODO: this should be number in algod client - defaultFrozen: asset.params.defaultFrozen, - manager: asset.params.manager, - reserve: asset.params.reserve, - freeze: asset.params.freeze, - clawback: asset.params.clawback, - unitName: asset.params.unitName, - // TODO: update algod client to make base64 string uint8array - unitNameB64: asset.params.unitNameB64 ? new Uint8Array(Buffer.from(asset.params.unitNameB64, 'base64')) : undefined, - assetName: asset.params.name, - assetNameB64: asset.params.nameB64 ? new Uint8Array(Buffer.from(asset.params.nameB64, 'base64')) : undefined, - url: asset.params.url, - urlB64: asset.params.urlB64 ? new Uint8Array(Buffer.from(asset.params.urlB64, 'base64')) : undefined, - metadataHash: asset.params.metadataHash ? new Uint8Array(Buffer.from(asset.params.metadataHash, 'base64')) : undefined, + try { + const asset = await this.algodClient.getAssetById(assetId) + + return { + assetId: asset.index, + creator: asset.params.creator, + total: asset.params.total, + decimals: Number(asset.params.decimals), + defaultFrozen: asset.params.defaultFrozen, + manager: asset.params.manager, + reserve: asset.params.reserve, + freeze: asset.params.freeze, + clawback: asset.params.clawback, + unitName: asset.params.unitName, + unitNameB64: asset.params.unitNameB64, + assetName: asset.params.name, + assetNameB64: asset.params.nameB64, + url: asset.params.url, + urlB64: asset.params.urlB64, + metadataHash: asset.params.metadataHash, + } + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw new AssetManagerError('ASSET_NOT_FOUND', `Asset not found: ${assetId}`, { assetId }, error) + } + throw new AssetManagerError('ALGOD_CLIENT_ERROR', 'Failed to fetch asset information', { assetId }, error) } } - /** Get account's asset information. - * Returns the raw algod AccountAssetInformation type. - * Access asset holding via `account_info.asset_holding` and asset params via `account_info.asset_params`. - */ + /** Get account's asset information. Returns the raw algod AccountAssetInformation type. */ async getAccountInformation(sender: string, assetId: bigint): Promise { - return await this.algodClient.accountAssetInformation(sender, Number(assetId)) + try { + return await this.algodClient.accountAssetInformation(sender, assetId) + } catch (error) { + if (error instanceof ApiError) { + if (error.status === 404) { + throw new AssetManagerError('NOT_OPTED_IN', `Account ${sender} is not opted into asset ${assetId}`, { + sender, + assetId, + }, error) + } + if (error.status === 400) { + throw new AssetManagerError('ACCOUNT_NOT_FOUND', `Account not found: ${sender}`, { sender }, error) + } + } + throw new AssetManagerError('ALGOD_CLIENT_ERROR', 'Failed to fetch account asset information', { sender, assetId }, error) + } } async bulkOptIn(account: string, assetIds: bigint[]): Promise { @@ -185,26 +156,44 @@ export class AssetManager { const composer = this.newComposer() - // Add asset opt-in transactions for each asset - for (const assetId of assetIds) { - const optInParams: AssetOptInParams = { + for (const rawAssetId of assetIds) { + const assetId = BigInt(rawAssetId) + const params: AssetOptInParams = { sender: account, assetId, } - composer.addAssetOptIn(optInParams) + try { + composer.addAssetOptIn(params) + } catch (error) { + throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-in for asset ${assetId}`, { assetId }, error) + } } - // Send the transaction group - const composerResults = await composer.send() - - // Map transaction IDs back to assets - const results: BulkAssetOptInOutResult[] = assetIds.map((assetId, index) => ({ - assetId, - transactionId: composerResults.transactionIds[index], - })) + try { + const result = await composer.send() + + if (result.results.length !== assetIds.length) { + throw new AssetManagerError( + 'COMPOSER_ERROR', + 'Composer returned an unexpected number of results', + { + expected: assetIds.length, + actual: result.results.length, + }, + ) + } - return results + return assetIds.map((rawAssetId, index) => ({ + assetId: BigInt(rawAssetId), + transactionId: result.results[index].transactionId, + })) + } catch (error) { + if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { + throw error + } + throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-in transactions', undefined, error) + } } async bulkOptOut(account: string, assetIds: bigint[], ensureZeroBalance?: boolean): Promise { @@ -214,52 +203,77 @@ export class AssetManager { const shouldCheckBalance = ensureZeroBalance ?? false - // If we need to check balances, verify they are all zero if (shouldCheckBalance) { - for (const assetId of assetIds) { - const accountInfo = await this.getAccountInformation(account, assetId) - const balance = accountInfo.assetHolding?.amount ?? 0 - if (balance > 0) { - throw new Error(`Account ${account} has non-zero balance ${balance} for asset ${assetId}`) + for (const rawAssetId of assetIds) { + const assetId = BigInt(rawAssetId) + const accountInfo = await this.getAccountInformation(account, assetId).catch((error: unknown) => { + if (error instanceof AssetManagerError && error.code === 'NOT_OPTED_IN') { + throw new AssetManagerError('NOT_OPTED_IN', `Account ${account} is not opted into asset ${assetId}`, { + account, + assetId, + }, error) + } + throw error + }) + + const balance = accountInfo.assetHolding?.amount ?? 0n + if (balance > 0n) { + throw new AssetManagerError('NON_ZERO_BALANCE', `Account ${account} has non-zero balance for asset ${assetId}`, { + account, + assetId, + balance, + }) } } } - // Fetch asset information to get creators - const assetCreators: string[] = [] - for (const assetId of assetIds) { - try { - const assetInfo = await this.getById(assetId) - assetCreators.push(assetInfo.creator) - } catch { - throw new Error(`Asset not found: ${assetId}`) - } + const creators: string[] = [] + for (const rawAssetId of assetIds) { + const assetId = BigInt(rawAssetId) + const assetInfo = await this.getById(assetId) + creators.push(assetInfo.creator) } const composer = this.newComposer() - // Add asset opt-out transactions for each asset - assetIds.forEach((assetId, index) => { - const creator = assetCreators[index] - - const optOutParams: AssetOptOutParams = { + assetIds.forEach((rawAssetId, index) => { + const assetId = BigInt(rawAssetId) + const params: AssetOptOutParams = { sender: account, assetId, - closeRemainderTo: creator, + closeRemainderTo: creators[index], } - composer.addAssetOptOut(optOutParams) + try { + composer.addAssetOptOut(params) + } catch (error) { + throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-out for asset ${assetId}`, { assetId }, error) + } }) - // Send the transaction group - const composerResults = await composer.send() - - // Map transaction IDs back to assets - const results: BulkAssetOptInOutResult[] = assetIds.map((assetId, index) => ({ - assetId, - transactionId: composerResults.transactionIds[index], - })) + try { + const result = await composer.send() + + if (result.results.length !== assetIds.length) { + throw new AssetManagerError( + 'COMPOSER_ERROR', + 'Composer returned an unexpected number of results', + { + expected: assetIds.length, + actual: result.results.length, + }, + ) + } - return results + return assetIds.map((rawAssetId, index) => ({ + assetId: BigInt(rawAssetId), + transactionId: result.results[index].transactionId, + })) + } catch (error) { + if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { + throw error + } + throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-out transactions', undefined, error) + } } } diff --git a/packages/typescript/algokit_utils/src/clients/client-manager.ts b/packages/typescript/algokit_utils/src/clients/client-manager.ts new file mode 100644 index 000000000..caa1f021a --- /dev/null +++ b/packages/typescript/algokit_utils/src/clients/client-manager.ts @@ -0,0 +1,371 @@ +import { AlgodClient, ApiError } from '@algorandfoundation/algod-client' +import { IndexerClient } from '@algorandfoundation/indexer-client' +import { KmdClient } from '@algorandfoundation/kmd-client' +import { Buffer } from 'buffer' + +import { + AlgoClientConfig, + AlgoConfig, + AlgorandService, + NetworkDetails, + TokenHeader, + genesisIdIsLocalNet, +} from './network-client' + +export interface ClientManagerClients { + algod: AlgodClient + indexer?: IndexerClient + kmd?: KmdClient +} + +interface NetworkCache { + value?: NetworkDetails + promise?: Promise +} + +export class ClientManager { + private readonly algodClient: AlgodClient + private readonly indexerClient?: IndexerClient + private readonly kmdClient?: KmdClient + private readonly networkCache: NetworkCache = {} + + constructor(clientsOrConfig: ClientManagerClients | AlgoConfig) { + const clients = 'algod' in clientsOrConfig + ? clientsOrConfig + : { + algod: ClientManager.getAlgodClient(clientsOrConfig.algodConfig), + indexer: clientsOrConfig.indexerConfig + ? ClientManager.getIndexerClient(clientsOrConfig.indexerConfig) + : undefined, + kmd: clientsOrConfig.kmdConfig ? ClientManager.getKmdClient(clientsOrConfig.kmdConfig) : undefined, + } + + this.algodClient = clients.algod + this.indexerClient = clients.indexer + this.kmdClient = clients.kmd + } + + /** Returns an Algod API client. */ + get algod(): AlgodClient { + return this.algodClient + } + + /** Returns an Indexer API client or throws if not configured. */ + get indexer(): IndexerClient { + if (!this.indexerClient) { + throw new Error('Attempt to use Indexer client without configuring one') + } + return this.indexerClient + } + + /** Returns an Indexer API client if present. */ + get indexerIfPresent(): IndexerClient | undefined { + return this.indexerClient + } + + /** Returns a KMD API client or throws if not configured. */ + get kmd(): KmdClient { + if (!this.kmdClient) { + throw new Error('Attempt to use KMD client without configuring one') + } + return this.kmdClient + } + + /** Returns a KMD API client if present. */ + get kmdIfPresent(): KmdClient | undefined { + return this.kmdClient + } + + /** Get details about the current network. */ + async network(): Promise { + if (this.networkCache.value) { + return this.networkCache.value + } + + if (!this.networkCache.promise) { + this.networkCache.promise = this.fetchNetworkDetails().then((details) => { + this.networkCache.value = details + this.networkCache.promise = undefined + return details + }).catch((error) => { + this.networkCache.promise = undefined + throw error + }) + } + + return this.networkCache.promise + } + + /** Returns true if the given genesis ID is associated with a LocalNet network. */ + static genesisIdIsLocalNet(genesisId: string): boolean { + return genesisIdIsLocalNet(genesisId) + } + + /** Returns true if the current network is LocalNet. */ + async isLocalNet(): Promise { + const network = await this.network() + return network.isLocalnet + } + + /** Returns true if the current network is TestNet. */ + async isTestNet(): Promise { + const network = await this.network() + return network.isTestnet + } + + /** Returns true if the current network is MainNet. */ + async isMainNet(): Promise { + const network = await this.network() + return network.isMainnet + } + + /** + * Derive configuration from the environment if possible, otherwise default to a localnet configuration. + */ + static getConfigFromEnvironmentOrLocalNet(): AlgoConfig { + try { + const algodConfig = this.getAlgodConfigFromEnvironment() + const indexerConfig = this.safeGetConfig(this.getIndexerConfigFromEnvironment.bind(this)) + + const isPublicNetwork = /mainnet|testnet/.test(algodConfig.server) + const kmdConfig = isPublicNetwork + ? undefined + : this.safeGetConfig(() => this.getKmdConfigFromEnvironment(algodConfig)) ?? { + server: algodConfig.server, + port: this.parsePort(process.env.KMD_PORT) ?? this.parsePort(process.env.ALGOD_PORT) ?? 4002, + token: process.env.KMD_TOKEN ?? process.env.ALGOD_TOKEN, + } + + return { + algodConfig, + indexerConfig, + kmdConfig, + } + } catch (error) { + return { + algodConfig: this.getDefaultLocalnetConfig(AlgorandService.Algod), + indexerConfig: this.getDefaultLocalnetConfig(AlgorandService.Indexer), + kmdConfig: this.getDefaultLocalnetConfig(AlgorandService.Kmd), + } + } + } + + /** + * Returns Indexer configuration derived from environment variables. + * @throws Error if required environment variables are missing. + */ + static getIndexerConfigFromEnvironment(): AlgoClientConfig { + const server = process.env.INDEXER_SERVER + if (!server) { + throw new Error('INDEXER_SERVER environment variable not found') + } + + const port = this.parsePort(process.env.INDEXER_PORT) + const token = process.env.INDEXER_TOKEN + + return { + server, + port, + token: token ?? undefined, + } + } + + /** + * Returns Algod configuration derived from environment variables. + * @throws Error if required environment variables are missing. + */ + static getAlgodConfigFromEnvironment(): AlgoClientConfig { + const server = process.env.ALGOD_SERVER + if (!server) { + throw new Error('ALGOD_SERVER environment variable not found') + } + + const port = this.parsePort(process.env.ALGOD_PORT) + const token = process.env.ALGOD_TOKEN + + return { + server, + port, + token: token ?? undefined, + } + } + + /** + * Returns KMD configuration derived from environment variables. + * Falls back to ALGOD_* variables if KMD_* are not provided. + * @throws Error if no server can be determined. + */ + static getKmdConfigFromEnvironment(fallbackAlgodConfig?: AlgoClientConfig): AlgoClientConfig { + const server = process.env.KMD_SERVER ?? fallbackAlgodConfig?.server ?? process.env.ALGOD_SERVER + if (!server) { + throw new Error('KMD_SERVER environment variable not found') + } + + const port = this.parsePort(process.env.KMD_PORT) + ?? fallbackAlgodConfig?.port + ?? this.parsePort(process.env.ALGOD_PORT) + ?? 4002 + + const token = process.env.KMD_TOKEN ?? process.env.ALGOD_TOKEN + + return { + server, + port, + token: token ?? undefined, + } + } + + /** + * Returns an Algonode configuration for the provided network and service. + */ + static getAlgonodeConfig(network: string, service: AlgorandService): AlgoClientConfig { + if (service === AlgorandService.Kmd) { + throw new Error('KMD is not available on algonode') + } + + const subdomain = service === AlgorandService.Algod ? 'api' : 'idx' + + return { + server: `https://${network}-${subdomain}.4160.nodely.dev`, + port: 443, + } + } + + /** + * Returns a default localnet configuration for the provided service. + */ + static getDefaultLocalnetConfig(service: AlgorandService): AlgoClientConfig { + const port = service === AlgorandService.Algod ? 4001 : service === AlgorandService.Indexer ? 8980 : 4002 + + return { + server: 'http://localhost', + port, + token: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + } + } + + /** + * Creates an Algod client for the given configuration. + */ + static getAlgodClient(config: AlgoClientConfig): AlgodClient { + const clientConfig = this.createHttpClientConfig(config, 'X-Algo-API-Token') + return new AlgodClient(clientConfig) + } + + /** + * Creates an Indexer client for the given configuration. + */ + static getIndexerClient(config: AlgoClientConfig): IndexerClient { + const clientConfig = this.createHttpClientConfig(config, 'X-Indexer-API-Token') + return new IndexerClient(clientConfig) + } + + /** + * Creates a KMD client for the given configuration. + */ + static getKmdClient(config: AlgoClientConfig): KmdClient { + const clientConfig = this.createHttpClientConfig(config, 'X-KMD-API-Token') + return new KmdClient(clientConfig) + } + + /** + * Creates an Algod client from environment variables. + */ + static getAlgodClientFromEnvironment(): AlgodClient { + return this.getAlgodClient(this.getAlgodConfigFromEnvironment()) + } + + /** + * Creates an Indexer client from environment variables. + */ + static getIndexerClientFromEnvironment(): IndexerClient { + return this.getIndexerClient(this.getIndexerConfigFromEnvironment()) + } + + /** + * Creates a KMD client from environment variables. + */ + static getKmdClientFromEnvironment(): KmdClient { + const algodConfig = this.safeGetConfig(this.getAlgodConfigFromEnvironment.bind(this)) + return this.getKmdClient(this.getKmdConfigFromEnvironment(algodConfig)) + } + + private async fetchNetworkDetails(): Promise { + try { + const params = await this.algodClient.transactionParams() + const genesisId = params.genesisId ?? 'unknown' + const genesisHash = Buffer.from(params.genesisHash ?? new Uint8Array()).toString('base64') + + return { + isTestnet: genesisId === 'testnet-v1.0', + isMainnet: genesisId === 'mainnet-v1.0', + isLocalnet: genesisIdIsLocalNet(genesisId), + genesisId, + genesisHash, + } + } catch (error) { + if (error instanceof ApiError) { + throw new Error(`Failed to fetch network details: ${error.message}`) + } + throw error + } + } + + private static createHttpClientConfig(config: AlgoClientConfig, defaultHeaderName: string) { + const baseUrl = this.buildBaseUrl(config) + const headers = this.buildHeaders(config.token, defaultHeaderName) + return headers ? { baseUrl, headers } : { baseUrl } + } + + private static buildHeaders(token: TokenHeader | undefined, defaultHeaderName: string): Record | undefined { + if (!token) { + return undefined + } + + if (typeof token === 'string') { + return { [defaultHeaderName]: token } + } + + return { ...token } + } + + private static buildBaseUrl(config: AlgoClientConfig): string { + const { server, port } = config + if (port === undefined || port === null || port === '') { + return server + } + + const portString = typeof port === 'string' ? port : port.toString() + + try { + const url = new URL(server) + url.port = portString + const normalized = url.toString() + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized + } catch { + if (/:[0-9]+$/.test(server)) { + return server + } + return `${server}:${portString}` + } + } + + private static parsePort(value: string | number | undefined | null): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value === 'number') { + return value + } + const parsed = Number(value) + return Number.isNaN(parsed) ? undefined : parsed + } + + private static safeGetConfig(getter: () => AlgoClientConfig): AlgoClientConfig | undefined { + try { + return getter() + } catch { + return undefined + } + } +} diff --git a/packages/typescript/algokit_utils/src/clients/network-client.ts b/packages/typescript/algokit_utils/src/clients/network-client.ts index bd105b74b..fd0cdea37 100644 --- a/packages/typescript/algokit_utils/src/clients/network-client.ts +++ b/packages/typescript/algokit_utils/src/clients/network-client.ts @@ -1,12 +1,32 @@ -export type TokenHeader = string | { [key: string]: string } +export type TokenHeader = string | Record + +/** Represents the different Algorand networks */ +export enum AlgorandNetwork { + /** Local development network */ + LocalNet = 'localnet', + /** Algorand TestNet */ + TestNet = 'testnet', + /** Algorand MainNet */ + MainNet = 'mainnet', +} + +/** Represents the different Algorand services */ +export enum AlgorandService { + /** Algorand daemon (algod) - provides access to the blockchain */ + Algod = 'algod', + /** Algorand indexer - provides historical blockchain data */ + Indexer = 'indexer', + /** Key Management Daemon (kmd) - provides key management functionality */ + Kmd = 'kmd', +} /** Config for an Algorand SDK client. */ export interface AlgoClientConfig { /** Base URL of the server e.g. http://localhost, https://testnet-api.algonode.cloud/, etc. */ server: string /** Optional port to use e.g. 4001, 443, etc. */ - port?: string | number - /** Optional token to use for API authentication */ + port?: number | string + /** Optional token or headers to use for API authentication */ token?: TokenHeader } @@ -20,11 +40,25 @@ export interface AlgoConfig { kmdConfig?: AlgoClientConfig } +/** Details about the currently connected network. */ +export interface NetworkDetails { + /** Whether the network is TestNet */ + isTestnet: boolean + /** Whether the network is MainNet */ + isMainnet: boolean + /** Whether the network is a LocalNet */ + isLocalnet: boolean + /** Genesis ID reported by the network */ + genesisId: string + /** Genesis hash reported by the network encoded as base64 */ + genesisHash: string +} + /** * Returns true if the given network genesisId is associated with a LocalNet network. * @param genesisId The network genesis ID * @returns Whether the given genesis ID is associated with a LocalNet network */ -export function genesisIdIsLocalNet(genesisId: string) { +export function genesisIdIsLocalNet(genesisId: string): boolean { return genesisId === 'devnet-v1' || genesisId === 'sandnet-v1' || genesisId === 'dockernet-v1' } diff --git a/packages/typescript/algokit_utils/src/index.ts b/packages/typescript/algokit_utils/src/index.ts index aa1fca666..a76a4ba32 100644 --- a/packages/typescript/algokit_utils/src/index.ts +++ b/packages/typescript/algokit_utils/src/index.ts @@ -1,3 +1,7 @@ export * from './algorand-client' +export * from './clients/asset-manager' +export * from './clients/client-manager' +export * from './clients/app-manager' +export * from './clients/network-client' export * from '@algorandfoundation/algokit-transact' From 28f8dc0913f5f939fe0f407d60d133434aa8f161 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 10 Oct 2025 15:43:55 +0200 Subject: [PATCH 02/18] chore: wip --- .../algokit_utils/src/algorand-client.ts | 222 ++++++++++++------ .../src/clients/asset-manager.ts | 156 ++++++++---- 2 files changed, 266 insertions(+), 112 deletions(-) diff --git a/packages/typescript/algokit_utils/src/algorand-client.ts b/packages/typescript/algokit_utils/src/algorand-client.ts index c205d9b67..f431879e2 100644 --- a/packages/typescript/algokit_utils/src/algorand-client.ts +++ b/packages/typescript/algokit_utils/src/algorand-client.ts @@ -1,91 +1,171 @@ -import { AlgoConfig } from './clients/network-client' -import { TransactionComposerConfig } from './transactions/composer' -import type { AlgodClient } from '@algorandfoundation/algod-client' +import { AppManager } from './clients/app-manager' +import { AssetManager } from './clients/asset-manager' +import { ClientManager } from './clients/client-manager' +import { AlgoConfig, AlgorandService } from './clients/network-client' +import { + TransactionComposer, + TransactionComposerConfig, + TransactionComposerParams, +} from './transactions/composer' +import { SignerGetter, TransactionSigner } from './transactions/common' +import type { TransactionParams } from '@algorandfoundation/algod-client' -export type AlgorandClientParams = { - clientConfig: AlgoConfig - composerConfig?: TransactionComposerConfig -} +export type AlgorandClientParams = + | { + clientConfig: AlgoConfig + clientManager?: undefined + composerConfig?: TransactionComposerConfig + } + | { + clientConfig?: undefined + clientManager: ClientManager + composerConfig?: TransactionComposerConfig + } -export type PaymentParams = { - sender: string - receiver: string - amount: bigint -} +class SignerRegistry implements SignerGetter { + private readonly signers = new Map() + private defaultSigner?: TransactionSigner -export type AssetConfigParams = { - sender: string - total: bigint - decimals: number - defaultFrozen: boolean - assetName: string - unitName: string - manager: string - reserve: string - freeze: string - clawback: string -} + setSigner(address: string, signer: TransactionSigner) { + this.signers.set(address, signer) + } -export type AppCreateParams = { - sender: string - approvalProgram: string - clearStateProgram: string - globalStateSchema: { numUints: number; numByteSlices: number } - localStateSchema: { numUints: number; numByteSlices: number } + clearSigner(address: string) { + this.signers.delete(address) + } + + setDefaultSigner(signer: TransactionSigner) { + this.defaultSigner = signer + } + + clearDefaultSigner() { + this.defaultSigner = undefined + } + + getSigner(address: string): TransactionSigner { + const signer = this.signers.get(address) ?? this.defaultSigner + if (!signer) { + throw new Error(`No signer registered for address ${address}. Use setSigner or setDefaultSigner to configure one.`) + } + return signer + } } /** * A client that brokers easy access to Algorand functionality. */ export class AlgorandClient { - private composerConfig?: TransactionComposerConfig + private readonly clientManager: ClientManager + private readonly assetManager: AssetManager + private readonly appManager: AppManager + private readonly signerRegistry = new SignerRegistry() + private readonly defaultComposerConfig?: TransactionComposerConfig constructor(params: AlgorandClientParams) { - this.composerConfig = params.composerConfig - } - - /** - * Creates a new transaction group - */ - newComposer(composerConfig?: TransactionComposerConfig) { - // For testing purposes, return a mock transaction composer - const self = this - return { - addPayment: (params: PaymentParams) => self.newComposer(composerConfig), - addAssetConfig: (params: AssetConfigParams) => self.newComposer(composerConfig), - addAppCreate: (params: AppCreateParams) => self.newComposer(composerConfig), - send: async () => ({ - confirmations: [ - { - txn: { id: `mock-tx-id-${Math.random().toString(36).substr(2, 9)}` }, - appId: Math.floor(Math.random() * 10000), - assetId: Math.floor(Math.random() * 10000), - }, - ], - }), + if (!params.clientManager && !params.clientConfig) { + throw new Error('AlgorandClient requires either a client configuration or an existing ClientManager instance.') } + + this.clientManager = params.clientManager ?? new ClientManager(params.clientConfig) + this.defaultComposerConfig = params.composerConfig + + this.assetManager = new AssetManager(this.clientManager.algod, () => this.newComposer()) + this.appManager = new AppManager(this.clientManager.algod) } - /** - * Send operations namespace - */ - get send() { - return { - payment: async (params: PaymentParams) => ({ - confirmations: [ - { - txn: { id: `mock-payment-tx-${Math.random().toString(36).substr(2, 9)}` }, - }, - ], - }), + /** Creates a new transaction composer pre-configured with the Algorand client context. */ + newComposer(composerConfig?: TransactionComposerConfig): TransactionComposer { + const params: TransactionComposerParams = { + algodClient: this.clientManager.algod, + signerGetter: this.signerRegistry, + composerConfig: composerConfig ?? this.defaultComposerConfig, } + return new TransactionComposer(params) + } + + /** Registers a signer for a specific address. */ + setSigner(address: string, signer: TransactionSigner): this { + this.signerRegistry.setSigner(address, signer) + return this + } + + /** Removes a previously registered signer for an address. */ + clearSigner(address: string): this { + this.signerRegistry.clearSigner(address) + return this + } + + /** Registers a default signer used when no address-specific signer exists. */ + setDefaultSigner(signer: TransactionSigner): this { + this.signerRegistry.setDefaultSigner(signer) + return this + } + + /** Clears the default signer. */ + clearDefaultSigner(): this { + this.signerRegistry.clearDefaultSigner() + return this + } + + /** Returns the underlying ClientManager. */ + get client(): ClientManager { + return this.clientManager + } + + /** Returns the AssetManager helper. */ + get asset(): AssetManager { + return this.assetManager + } + + /** Returns the AppManager helper. */ + get app(): AppManager { + return this.appManager + } + + /** Retrieves suggested transaction parameters from algod. */ + async getSuggestedParams(): Promise { + return await this.clientManager.algod.transactionParams() + } + + /** Creates an AlgorandClient from a raw network configuration. */ + static fromConfig(clientConfig: AlgoConfig, composerConfig?: TransactionComposerConfig): AlgorandClient { + return new AlgorandClient({ clientConfig, composerConfig }) + } + + /** Creates an AlgorandClient from an existing ClientManager. */ + static fromClientManager(clientManager: ClientManager, composerConfig?: TransactionComposerConfig): AlgorandClient { + return new AlgorandClient({ clientManager, composerConfig }) + } + + /** Creates an AlgorandClient configured for a local development network. */ + static localnet(composerConfig?: TransactionComposerConfig): AlgorandClient { + return AlgorandClient.fromConfig({ + algodConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Algod), + indexerConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Indexer), + kmdConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Kmd), + }, composerConfig) + } + + /** Creates an AlgorandClient configured for Algonode TestNet. */ + static testnet(composerConfig?: TransactionComposerConfig): AlgorandClient { + return AlgorandClient.fromConfig({ + algodConfig: ClientManager.getAlgonodeConfig('testnet', AlgorandService.Algod), + indexerConfig: ClientManager.getAlgonodeConfig('testnet', AlgorandService.Indexer), + kmdConfig: undefined, + }, composerConfig) + } + + /** Creates an AlgorandClient configured for Algonode MainNet. */ + static mainnet(composerConfig?: TransactionComposerConfig): AlgorandClient { + return AlgorandClient.fromConfig({ + algodConfig: ClientManager.getAlgonodeConfig('mainnet', AlgorandService.Algod), + indexerConfig: ClientManager.getAlgonodeConfig('mainnet', AlgorandService.Indexer), + kmdConfig: undefined, + }, composerConfig) } - /** - * Set a signer for an address - */ - setSigner(address: string, signer: any): void { - // For testing purposes, just store the signer reference - console.log(`Setting signer for address ${address}`) + /** Creates an AlgorandClient from environment configuration or defaults to localnet. */ + static fromEnvironment(composerConfig?: TransactionComposerConfig): AlgorandClient { + return AlgorandClient.fromConfig(ClientManager.getConfigFromEnvironmentOrLocalNet(), composerConfig) } } diff --git a/packages/typescript/algokit_utils/src/clients/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index 038c32161..6504b4322 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -19,49 +19,116 @@ export interface AssetInformation { /** The ID of the asset. */ assetId: bigint - /** The address of the account that created the asset. */ + /** The address of the account that created the asset. + * + * This is the address where the parameters for this asset can be found, + * and also the address where unwanted asset units can be sent when + * closing out an asset position and opting-out of the asset. + */ creator: string - /** The total amount of the smallest divisible (decimal) units that were created of the asset. */ + /** The total amount of the smallest divisible (decimal) units that were created of the asset. + * + * For example, if `decimals` is, say, 2, then for every 100 `total` there is 1 whole unit. + */ total: bigint - /** The amount of decimal places the asset was created with. */ + /** The amount of decimal places the asset was created with. + * + * * 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. + */ decimals: number - /** Whether the asset was frozen by default for all accounts. */ + /** Whether the asset was frozen by default for all accounts. + * + * 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. + */ defaultFrozen?: boolean - /** The address of the optional account that can manage the configuration of the asset and destroy it. */ + /** The address of the optional account that can manage the configuration of the asset and destroy it. + * + * If not set the asset is permanently immutable. + */ manager?: string - /** The address of the optional account that holds the reserve (uncirculated supply) units of the asset. */ + /** 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 the field is permanently empty. + */ reserve?: string - /** The address of the optional account that can be used to freeze or unfreeze holdings of this asset for any account. */ + /** 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 the field is permanently empty. + */ freeze?: string - /** The address of the optional account that can clawback holdings of this asset from any account. */ + /** The address of the optional account that can clawback holdings of this asset from any account. + * + * The clawback account has the ability to **unconditionally take assets from any account**. + * + * If empty, clawback is not permitted. + * + * If not set the field is permanently empty. + */ clawback?: string - /** The optional name of the unit of this asset (e.g. ticker name). */ + /** The optional name of the unit of this asset (e.g. ticker name). + * + * Max size is 8 bytes. + */ unitName?: string - /** The optional name of the unit of this asset as bytes. */ + /** The optional name of the unit of this asset as bytes. + * + * Max size is 8 bytes. + */ unitNameB64?: Uint8Array - /** The optional name of the asset. */ + /** The optional name of the asset. + * + * Max size is 32 bytes. + */ assetName?: string - /** The optional name of the asset as bytes. */ + /** The optional name of the asset as bytes. + * + * Max size is 32 bytes. + */ assetNameB64?: Uint8Array - /** Optional URL where more information about the asset can be retrieved (e.g. metadata). */ + /** Optional URL where more information about the asset can be retrieved (e.g. metadata). + * + * Max size is 96 bytes. + */ url?: string - /** Optional URL where more information about the asset can be retrieved as bytes. */ + /** Optional URL where more information about the asset can be retrieved as bytes. + * + * Max size is 96 bytes. + */ urlB64?: Uint8Array - /** 32-byte hash of some metadata that is relevant to the asset and/or asset holders. */ + /** 32-byte hash of some metadata that is relevant to the asset and/or asset holders. + * + * The format of this metadata is up to the application. + */ metadataHash?: Uint8Array } @@ -98,7 +165,9 @@ export class AssetManager { this.newComposer = newComposer } - /** Get asset information by asset ID. Returns a convenient, flattened view of the asset information. */ + /** Get asset information by asset ID + * Returns a convenient, flattened view of the asset information. + */ async getById(assetId: bigint): Promise { try { const asset = await this.algodClient.getAssetById(assetId) @@ -129,17 +198,25 @@ export class AssetManager { } } - /** Get account's asset information. Returns the raw algod AccountAssetInformation type. */ + /** Get account's asset information. + * Returns the raw algod AccountAssetInformation type. + * Access asset holding via `account_info.asset_holding` and asset params via `account_info.asset_params`. + */ async getAccountInformation(sender: string, assetId: bigint): Promise { try { return await this.algodClient.accountAssetInformation(sender, assetId) } catch (error) { if (error instanceof ApiError) { if (error.status === 404) { - throw new AssetManagerError('NOT_OPTED_IN', `Account ${sender} is not opted into asset ${assetId}`, { - sender, - assetId, - }, error) + throw new AssetManagerError( + 'NOT_OPTED_IN', + `Account ${sender} is not opted into asset ${assetId}`, + { + sender, + assetId, + }, + error, + ) } if (error.status === 400) { throw new AssetManagerError('ACCOUNT_NOT_FOUND', `Account not found: ${sender}`, { sender }, error) @@ -174,14 +251,10 @@ export class AssetManager { const result = await composer.send() if (result.results.length !== assetIds.length) { - throw new AssetManagerError( - 'COMPOSER_ERROR', - 'Composer returned an unexpected number of results', - { - expected: assetIds.length, - actual: result.results.length, - }, - ) + throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { + expected: assetIds.length, + actual: result.results.length, + }) } return assetIds.map((rawAssetId, index) => ({ @@ -208,10 +281,15 @@ export class AssetManager { const assetId = BigInt(rawAssetId) const accountInfo = await this.getAccountInformation(account, assetId).catch((error: unknown) => { if (error instanceof AssetManagerError && error.code === 'NOT_OPTED_IN') { - throw new AssetManagerError('NOT_OPTED_IN', `Account ${account} is not opted into asset ${assetId}`, { - account, - assetId, - }, error) + throw new AssetManagerError( + 'NOT_OPTED_IN', + `Account ${account} is not opted into asset ${assetId}`, + { + account, + assetId, + }, + error, + ) } throw error }) @@ -255,14 +333,10 @@ export class AssetManager { const result = await composer.send() if (result.results.length !== assetIds.length) { - throw new AssetManagerError( - 'COMPOSER_ERROR', - 'Composer returned an unexpected number of results', - { - expected: assetIds.length, - actual: result.results.length, - }, - ) + throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { + expected: assetIds.length, + actual: result.results.length, + }) } return assetIds.map((rawAssetId, index) => ({ From 7303b9709226a104d2f3c4db92ad198e7b0ea169 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:27:35 +0200 Subject: [PATCH 03/18] feat(ts): batch asset bulk opt operations --- .../tests/clients/asset_manager.rs | 67 ++++++- .../src/clients/asset-manager.ts | 168 +++++++++++------- 2 files changed, 168 insertions(+), 67 deletions(-) diff --git a/crates/algokit_utils/tests/clients/asset_manager.rs b/crates/algokit_utils/tests/clients/asset_manager.rs index 4a4997542..91362be20 100644 --- a/crates/algokit_utils/tests/clients/asset_manager.rs +++ b/crates/algokit_utils/tests/clients/asset_manager.rs @@ -1,4 +1,4 @@ -use algokit_transact::Address; +use algokit_transact::{Address, constants::MAX_TX_GROUP_SIZE}; use algokit_utils::{ clients::asset_manager::AssetManagerError, transactions::{AssetCreateParams, AssetOptInParams}, @@ -185,6 +185,31 @@ async fn test_bulk_opt_in_success(#[future] algorand_fixture: AlgorandFixtureRes Ok(()) } +#[rstest] +#[tokio::test] +async fn test_bulk_opt_in_batches(#[future] algorand_fixture: AlgorandFixtureResult) -> TestResult { + let mut algorand_fixture = algorand_fixture.await?; + let asset_count = MAX_TX_GROUP_SIZE + 4; + + let assets = create_multiple_test_assets(&mut algorand_fixture, asset_count).await?; + let asset_ids: Vec = assets.iter().map(|(id, _)| *id).collect(); + + let opt_in_account = algorand_fixture.generate_account(None).await?; + let opt_in_address = opt_in_account.account().address(); + + let asset_manager = algorand_fixture.algorand_client.asset(); + let results = asset_manager + .bulk_opt_in(&opt_in_address, &asset_ids) + .await?; + + assert_eq!(results.len(), asset_count); + for (expected, actual) in asset_ids.iter().zip(results.iter()) { + assert_eq!(expected, &actual.asset_id); + } + + Ok(()) +} + /// Test bulk opt-in with empty asset list #[rstest] #[tokio::test] @@ -272,6 +297,46 @@ async fn test_bulk_opt_out_success( Ok(()) } +#[rstest] +#[tokio::test] +async fn test_bulk_opt_out_batches( + #[future] algorand_fixture: AlgorandFixtureResult, +) -> TestResult { + let mut algorand_fixture = algorand_fixture.await?; + let asset_count = MAX_TX_GROUP_SIZE + 2; + + let assets = create_multiple_test_assets(&mut algorand_fixture, asset_count).await?; + let asset_ids: Vec = assets.iter().map(|(id, _)| *id).collect(); + + let test_account = algorand_fixture.generate_account(None).await?; + let test_address = test_account.account().address(); + + let asset_manager = algorand_fixture.algorand_client.asset(); + + let mut composer = algorand_fixture.algorand_client.new_composer(None); + for &asset_id in &asset_ids { + let opt_in_params = AssetOptInParams { + sender: test_address.clone(), + signer: Some(Arc::new(test_account.clone())), + asset_id, + ..Default::default() + }; + composer.add_asset_opt_in(opt_in_params)?; + } + composer.send(Default::default()).await?; + + let results = asset_manager + .bulk_opt_out(&test_address, &asset_ids, None) + .await?; + + assert_eq!(results.len(), asset_count); + for (expected, actual) in asset_ids.iter().zip(results.iter()) { + assert_eq!(expected, &actual.asset_id); + } + + Ok(()) +} + /// Test bulk opt-out with empty list #[rstest] #[tokio::test] diff --git a/packages/typescript/algokit_utils/src/clients/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index 6504b4322..c3f9db122 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -1,6 +1,21 @@ import { AccountAssetInformation, AlgodClient, ApiError } from '@algorandfoundation/algod-client' import { AssetOptInParams, AssetOptOutParams } from '../transactions/asset-transfer' import { TransactionComposer } from '../transactions/composer' +import { MAX_TX_GROUP_SIZE } from '@algorandfoundation/algokit-common' + +const chunkArray = (items: T[], size: number): T[][] => { + if (size <= 0) { + throw new Error('Chunk size must be greater than zero') + } + if (items.length <= size) { + return [items.slice()] + } + const chunks: T[][] = [] + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)) + } + return chunks +} /** Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets. */ export interface BulkAssetOptInOutResult { @@ -99,7 +114,7 @@ export interface AssetInformation { * * Max size is 8 bytes. */ - unitNameB64?: Uint8Array + unitNameAsBytes?: Uint8Array /** The optional name of the asset. * @@ -111,7 +126,7 @@ export interface AssetInformation { * * Max size is 32 bytes. */ - assetNameB64?: Uint8Array + assetNameAsBytes?: Uint8Array /** Optional URL where more information about the asset can be retrieved (e.g. metadata). * @@ -123,7 +138,7 @@ export interface AssetInformation { * * Max size is 96 bytes. */ - urlB64?: Uint8Array + urlAsBytes?: Uint8Array /** 32-byte hash of some metadata that is relevant to the asset and/or asset holders. * @@ -183,10 +198,13 @@ export class AssetManager { freeze: asset.params.freeze, clawback: asset.params.clawback, unitName: asset.params.unitName, + unitNameAsBytes: asset.params.unitNameB64, unitNameB64: asset.params.unitNameB64, assetName: asset.params.name, + assetNameAsBytes: asset.params.nameB64, assetNameB64: asset.params.nameB64, url: asset.params.url, + urlAsBytes: asset.params.urlB64, urlB64: asset.params.urlB64, metadataHash: asset.params.metadataHash, } @@ -231,42 +249,50 @@ export class AssetManager { return [] } - const composer = this.newComposer() + const normalizedAssetIds = assetIds.map((assetId) => BigInt(assetId)) + const results: BulkAssetOptInOutResult[] = [] + + for (const batch of chunkArray(normalizedAssetIds, MAX_TX_GROUP_SIZE)) { + const composer = this.newComposer() - for (const rawAssetId of assetIds) { - const assetId = BigInt(rawAssetId) - const params: AssetOptInParams = { - sender: account, - assetId, + for (const assetId of batch) { + const params: AssetOptInParams = { + sender: account, + assetId, + } + + try { + composer.addAssetOptIn(params) + } catch (error) { + throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-in for asset ${assetId}`, { assetId }, error) + } } try { - composer.addAssetOptIn(params) - } catch (error) { - throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-in for asset ${assetId}`, { assetId }, error) - } - } + const result = await composer.send() - try { - const result = await composer.send() + if (result.results.length !== batch.length) { + throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { + expected: batch.length, + actual: result.results.length, + }) + } - if (result.results.length !== assetIds.length) { - throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { - expected: assetIds.length, - actual: result.results.length, + batch.forEach((assetId, index) => { + results.push({ + assetId, + transactionId: result.results[index].transactionId, + }) }) + } catch (error) { + if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { + throw error + } + throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-in transactions', undefined, error) } - - return assetIds.map((rawAssetId, index) => ({ - assetId: BigInt(rawAssetId), - transactionId: result.results[index].transactionId, - })) - } catch (error) { - if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { - throw error - } - throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-in transactions', undefined, error) } + + return results } async bulkOptOut(account: string, assetIds: bigint[], ensureZeroBalance?: boolean): Promise { @@ -274,11 +300,12 @@ export class AssetManager { return [] } + const normalizedAssetIds = assetIds.map((assetId) => BigInt(assetId)) const shouldCheckBalance = ensureZeroBalance ?? false + const results: BulkAssetOptInOutResult[] = [] if (shouldCheckBalance) { - for (const rawAssetId of assetIds) { - const assetId = BigInt(rawAssetId) + for (const assetId of normalizedAssetIds) { const accountInfo = await this.getAccountInformation(account, assetId).catch((error: unknown) => { if (error instanceof AssetManagerError && error.code === 'NOT_OPTED_IN') { throw new AssetManagerError( @@ -305,49 +332,58 @@ export class AssetManager { } } - const creators: string[] = [] - for (const rawAssetId of assetIds) { - const assetId = BigInt(rawAssetId) - const assetInfo = await this.getById(assetId) - creators.push(assetInfo.creator) - } + const creatorCache = new Map() - const composer = this.newComposer() + for (const batch of chunkArray(normalizedAssetIds, MAX_TX_GROUP_SIZE)) { + const composer = this.newComposer() - assetIds.forEach((rawAssetId, index) => { - const assetId = BigInt(rawAssetId) - const params: AssetOptOutParams = { - sender: account, - assetId, - closeRemainderTo: creators[index], + const creators: string[] = [] + for (const assetId of batch) { + if (!creatorCache.has(assetId)) { + const assetInfo = await this.getById(assetId) + creatorCache.set(assetId, assetInfo.creator) + } + creators.push(creatorCache.get(assetId)!) } + batch.forEach((assetId, index) => { + const params: AssetOptOutParams = { + sender: account, + assetId, + closeRemainderTo: creators[index], + } + + try { + composer.addAssetOptOut(params) + } catch (error) { + throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-out for asset ${assetId}`, { assetId }, error) + } + }) + try { - composer.addAssetOptOut(params) - } catch (error) { - throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-out for asset ${assetId}`, { assetId }, error) - } - }) + const result = await composer.send() - try { - const result = await composer.send() + if (result.results.length !== batch.length) { + throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { + expected: batch.length, + actual: result.results.length, + }) + } - if (result.results.length !== assetIds.length) { - throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { - expected: assetIds.length, - actual: result.results.length, + batch.forEach((assetId, index) => { + results.push({ + assetId, + transactionId: result.results[index].transactionId, + }) }) + } catch (error) { + if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { + throw error + } + throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-out transactions', undefined, error) } - - return assetIds.map((rawAssetId, index) => ({ - assetId: BigInt(rawAssetId), - transactionId: result.results[index].transactionId, - })) - } catch (error) { - if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { - throw error - } - throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-out transactions', undefined, error) } + + return results } } From c6bb808df7c1f6f1b2950495e330e78aea7ad7d7 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:28:38 +0200 Subject: [PATCH 04/18] feat(rust): batch asset bulk operations --- .../src/clients/asset_manager.rs | 131 +++++++++--------- 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/crates/algokit_utils/src/clients/asset_manager.rs b/crates/algokit_utils/src/clients/asset_manager.rs index d1eb311cd..2b9121e64 100644 --- a/crates/algokit_utils/src/clients/asset_manager.rs +++ b/crates/algokit_utils/src/clients/asset_manager.rs @@ -1,8 +1,8 @@ use algod_client::apis::{AlgodClient, Error as AlgodError}; use algod_client::models::{AccountAssetInformation as AlgodAccountAssetInformation, Asset}; -use algokit_transact::Address; +use algokit_transact::{Address, constants::MAX_TX_GROUP_SIZE}; use snafu::Snafu; -use std::{str::FromStr, sync::Arc}; +use std::{collections::HashMap, str::FromStr, sync::Arc}; use crate::transactions::{ AssetOptInParams, AssetOptOutParams, ComposerError, TransactionComposer, @@ -93,7 +93,7 @@ pub struct AssetInformation { /// The optional name of the unit of this asset as bytes. /// /// Max size is 8 bytes. - pub unit_name_b64: Option>, + pub unit_name_bytes: Option>, /// The optional name of the asset. /// @@ -103,7 +103,7 @@ pub struct AssetInformation { /// The optional name of the asset as bytes. /// /// Max size is 32 bytes. - pub asset_name_b64: Option>, + pub asset_name_bytes: Option>, /// Optional URL where more information about the asset can be retrieved (e.g. metadata). /// @@ -113,7 +113,7 @@ pub struct AssetInformation { /// Optional URL where more information about the asset can be retrieved as bytes. /// /// Max size is 96 bytes. - pub url_b64: Option>, + pub url_bytes: Option>, /// 32-byte hash of some metadata that is relevant to the asset and/or asset holders. /// @@ -134,11 +134,11 @@ impl From for AssetInformation { freeze: asset.params.freeze, clawback: asset.params.clawback, unit_name: asset.params.unit_name, - unit_name_b64: asset.params.unit_name_b64, + unit_name_bytes: asset.params.unit_name_b64, asset_name: asset.params.name, - asset_name_b64: asset.params.name_b64, + asset_name_bytes: asset.params.name_b64, url: asset.params.url, - url_b64: asset.params.url_b64, + url_bytes: asset.params.url_b64, metadata_hash: asset.params.metadata_hash, } } @@ -203,36 +203,35 @@ impl AssetManager { return Ok(Vec::new()); } - let mut composer = (self.new_composer)(None); + let mut bulk_results = Vec::with_capacity(asset_ids.len()); - // Add asset opt-in transactions for each asset - for &asset_id in asset_ids { - let opt_in_params = AssetOptInParams { - sender: account.clone(), - asset_id, - ..Default::default() - }; - - composer - .add_asset_opt_in(opt_in_params) + for asset_chunk in asset_ids.chunks(MAX_TX_GROUP_SIZE) { + let mut composer = (self.new_composer)(None); + + for &asset_id in asset_chunk { + let opt_in_params = AssetOptInParams { + sender: account.clone(), + asset_id, + ..Default::default() + }; + + composer + .add_asset_opt_in(opt_in_params) + .map_err(|e| AssetManagerError::ComposerError { source: e })?; + } + + let composer_result = composer + .send(Default::default()) + .await .map_err(|e| AssetManagerError::ComposerError { source: e })?; - } - // Send the transaction group - let composer_result = composer - .send(Default::default()) - .await - .map_err(|e| AssetManagerError::ComposerError { source: e })?; - - // Map transaction IDs back to assets - let bulk_results: Vec = asset_ids - .iter() - .zip(composer_result.results.iter()) - .map(|(&asset_id, result)| BulkAssetOptInOutResult { - asset_id, - transaction_id: result.transaction_id.clone(), - }) - .collect(); + bulk_results.extend(asset_chunk.iter().zip(composer_result.results.iter()).map( + |(&asset_id, result)| BulkAssetOptInOutResult { + asset_id, + transaction_id: result.transaction_id.clone(), + }, + )); + } Ok(bulk_results) } @@ -269,45 +268,49 @@ impl AssetManager { } // Fetch asset information to get creators - let mut asset_creators = Vec::new(); + let mut asset_creators = HashMap::with_capacity(asset_ids.len()); for &asset_id in asset_ids { let asset_info = self.get_by_id(asset_id).await?; let creator = Address::from_str(&asset_info.creator) .map_err(|_| AssetManagerError::AssetNotFound { asset_id })?; - asset_creators.push(creator); + asset_creators.insert(asset_id, creator); } - let mut composer = (self.new_composer)(None); + let mut bulk_results = Vec::with_capacity(asset_ids.len()); - // Add asset opt-out transactions for each asset - for (i, &asset_id) in asset_ids.iter().enumerate() { - let opt_out_params = AssetOptOutParams { - sender: account.clone(), - asset_id, - close_remainder_to: Some(asset_creators[i].clone()), - ..Default::default() - }; + for asset_chunk in asset_ids.chunks(MAX_TX_GROUP_SIZE) { + let mut composer = (self.new_composer)(None); - composer - .add_asset_opt_out(opt_out_params) + for &asset_id in asset_chunk { + let creator = asset_creators + .get(&asset_id) + .cloned() + .expect("Creator information should be available for all asset IDs"); + + let opt_out_params = AssetOptOutParams { + sender: account.clone(), + asset_id, + close_remainder_to: Some(creator), + ..Default::default() + }; + + composer + .add_asset_opt_out(opt_out_params) + .map_err(|e| AssetManagerError::ComposerError { source: e })?; + } + + let composer_result = composer + .send(Default::default()) + .await .map_err(|e| AssetManagerError::ComposerError { source: e })?; - } - // Send the transaction group - let composer_result = composer - .send(Default::default()) - .await - .map_err(|e| AssetManagerError::ComposerError { source: e })?; - - // Map transaction IDs back to assets - let bulk_results: Vec = asset_ids - .iter() - .zip(composer_result.results.iter()) - .map(|(&asset_id, result)| BulkAssetOptInOutResult { - asset_id, - transaction_id: result.transaction_id.clone(), - }) - .collect(); + bulk_results.extend(asset_chunk.iter().zip(composer_result.results.iter()).map( + |(&asset_id, result)| BulkAssetOptInOutResult { + asset_id, + transaction_id: result.transaction_id.clone(), + }, + )); + } Ok(bulk_results) } From 3647094041a36c48acd75ab650ac3400919d4864 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:29:56 +0200 Subject: [PATCH 05/18] refactor: expand supported network aliases in network client in rs and ts --- crates/algokit_utils/src/clients/network_client.rs | 11 +++++++++-- .../algokit_utils/src/clients/network-client.ts | 14 +++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/algokit_utils/src/clients/network_client.rs b/crates/algokit_utils/src/clients/network_client.rs index ed3bd1887..02899a973 100644 --- a/crates/algokit_utils/src/clients/network_client.rs +++ b/crates/algokit_utils/src/clients/network_client.rs @@ -1,5 +1,8 @@ use std::collections::HashMap; +const TESTNET_GENESIS_IDS: [&str; 3] = ["testnet-v1.0", "testnet-v1", "testnet"]; +const MAINNET_GENESIS_IDS: [&str; 3] = ["mainnet-v1.0", "mainnet-v1", "mainnet"]; + #[derive(Debug, Clone)] pub enum TokenHeader { String(String), @@ -122,8 +125,12 @@ pub struct NetworkDetails { impl NetworkDetails { pub fn new(genesis_id: String, genesis_hash: String) -> Self { let is_localnet = genesis_id_is_localnet(&genesis_id); - let is_testnet = genesis_id == "testnet-v1.0"; - let is_mainnet = genesis_id == "mainnet-v1.0"; + let is_testnet = TESTNET_GENESIS_IDS + .iter() + .any(|known| known.eq_ignore_ascii_case(&genesis_id)); + let is_mainnet = MAINNET_GENESIS_IDS + .iter() + .any(|known| known.eq_ignore_ascii_case(&genesis_id)); Self { is_testnet, diff --git a/packages/typescript/algokit_utils/src/clients/network-client.ts b/packages/typescript/algokit_utils/src/clients/network-client.ts index fd0cdea37..b27b1898d 100644 --- a/packages/typescript/algokit_utils/src/clients/network-client.ts +++ b/packages/typescript/algokit_utils/src/clients/network-client.ts @@ -20,6 +20,10 @@ export enum AlgorandService { Kmd = 'kmd', } +const LOCALNET_GENESIS_IDS = new Set(['devnet-v1', 'sandnet-v1', 'dockernet-v1']) +const TESTNET_GENESIS_IDS = new Set(['testnet-v1.0', 'testnet-v1', 'testnet']) +const MAINNET_GENESIS_IDS = new Set(['mainnet-v1.0', 'mainnet-v1', 'mainnet']) + /** Config for an Algorand SDK client. */ export interface AlgoClientConfig { /** Base URL of the server e.g. http://localhost, https://testnet-api.algonode.cloud/, etc. */ @@ -60,5 +64,13 @@ export interface NetworkDetails { * @returns Whether the given genesis ID is associated with a LocalNet network */ export function genesisIdIsLocalNet(genesisId: string): boolean { - return genesisId === 'devnet-v1' || genesisId === 'sandnet-v1' || genesisId === 'dockernet-v1' + return LOCALNET_GENESIS_IDS.has(genesisId) +} + +export function genesisIdIsTestnet(genesisId: string): boolean { + return TESTNET_GENESIS_IDS.has(genesisId) +} + +export function genesisIdIsMainnet(genesisId: string): boolean { + return MAINNET_GENESIS_IDS.has(genesisId) } From ddf31d5f07814d04aa44076949ebf0121c6c8e55 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:31:05 +0200 Subject: [PATCH 06/18] feat: add default retryable http client in ts (adopted from legacy utils-ts) --- .../src/clients/client-manager.ts | 270 +++++++++++++----- .../src/clients/http/retry-http-request.ts | 225 +++++++++++++++ .../typescript/algokit_utils/src/index.ts | 1 + 3 files changed, 427 insertions(+), 69 deletions(-) create mode 100644 packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts diff --git a/packages/typescript/algokit_utils/src/clients/client-manager.ts b/packages/typescript/algokit_utils/src/clients/client-manager.ts index caa1f021a..ebe31b477 100644 --- a/packages/typescript/algokit_utils/src/clients/client-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/client-manager.ts @@ -1,4 +1,4 @@ -import { AlgodClient, ApiError } from '@algorandfoundation/algod-client' +import { AlgodClient, ApiError, type BaseHttpRequest, type ClientConfig as HttpClientConfig } from '@algorandfoundation/algod-client' import { IndexerClient } from '@algorandfoundation/indexer-client' import { KmdClient } from '@algorandfoundation/kmd-client' import { Buffer } from 'buffer' @@ -10,7 +10,10 @@ import { NetworkDetails, TokenHeader, genesisIdIsLocalNet, + genesisIdIsMainnet, + genesisIdIsTestnet, } from './network-client' +import { RetryHttpRequest } from './http/retry-http-request' export interface ClientManagerClients { algod: AlgodClient @@ -23,22 +26,43 @@ interface NetworkCache { promise?: Promise } +type HttpClientFactoryResult = { + clientConfig: HttpClientConfig + request?: BaseHttpRequest +} + +type HttpClientFactory = (config: AlgoClientConfig, defaultHeaderName: string) => HttpClientFactoryResult + +export type ClientManagerErrorCode = 'INDEXER_NOT_CONFIGURED' | 'KMD_NOT_CONFIGURED' | 'ENVIRONMENT_MISSING' + +export class ClientManagerError extends Error { + readonly code: ClientManagerErrorCode + readonly details?: Record + + constructor(code: ClientManagerErrorCode, message: string, details?: Record) { + super(message) + this.name = 'ClientManagerError' + this.code = code + this.details = details + } +} + export class ClientManager { + private static httpClientFactory?: HttpClientFactory private readonly algodClient: AlgodClient private readonly indexerClient?: IndexerClient private readonly kmdClient?: KmdClient private readonly networkCache: NetworkCache = {} constructor(clientsOrConfig: ClientManagerClients | AlgoConfig) { - const clients = 'algod' in clientsOrConfig - ? clientsOrConfig - : { - algod: ClientManager.getAlgodClient(clientsOrConfig.algodConfig), - indexer: clientsOrConfig.indexerConfig - ? ClientManager.getIndexerClient(clientsOrConfig.indexerConfig) - : undefined, - kmd: clientsOrConfig.kmdConfig ? ClientManager.getKmdClient(clientsOrConfig.kmdConfig) : undefined, - } + const clients = + 'algod' in clientsOrConfig + ? clientsOrConfig + : { + algod: ClientManager.getAlgodClient(clientsOrConfig.algodConfig), + indexer: clientsOrConfig.indexerConfig ? ClientManager.getIndexerClient(clientsOrConfig.indexerConfig) : undefined, + kmd: clientsOrConfig.kmdConfig ? ClientManager.getKmdClient(clientsOrConfig.kmdConfig) : undefined, + } this.algodClient = clients.algod this.indexerClient = clients.indexer @@ -53,7 +77,7 @@ export class ClientManager { /** Returns an Indexer API client or throws if not configured. */ get indexer(): IndexerClient { if (!this.indexerClient) { - throw new Error('Attempt to use Indexer client without configuring one') + throw new ClientManagerError('INDEXER_NOT_CONFIGURED', 'Attempt to use Indexer client without configuring one') } return this.indexerClient } @@ -66,7 +90,7 @@ export class ClientManager { /** Returns a KMD API client or throws if not configured. */ get kmd(): KmdClient { if (!this.kmdClient) { - throw new Error('Attempt to use KMD client without configuring one') + throw new ClientManagerError('KMD_NOT_CONFIGURED', 'Attempt to use KMD client without configuring one') } return this.kmdClient } @@ -83,14 +107,16 @@ export class ClientManager { } if (!this.networkCache.promise) { - this.networkCache.promise = this.fetchNetworkDetails().then((details) => { - this.networkCache.value = details - this.networkCache.promise = undefined - return details - }).catch((error) => { - this.networkCache.promise = undefined - throw error - }) + this.networkCache.promise = this.fetchNetworkDetails() + .then((details) => { + this.networkCache.value = details + this.networkCache.promise = undefined + return details + }) + .catch((error) => { + this.networkCache.promise = undefined + throw error + }) } return this.networkCache.promise @@ -119,34 +145,101 @@ export class ClientManager { return network.isMainnet } + /** + * TODO: Provide TestNet dispenser helper once dependencies are ported from legacy algokit-utils-ts. + */ + getTestNetDispenser(_params: unknown): never { + throw new Error('TODO: getTestNetDispenser is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide environment-based TestNet dispenser helper once dependencies are ported from legacy algokit-utils-ts. + */ + getTestNetDispenserFromEnvironment(_params?: unknown): never { + throw new Error('TODO: getTestNetDispenserFromEnvironment is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide app factory support once app client abstractions are ported from legacy algokit-utils-ts. + */ + getAppFactory(_params: unknown): never { + throw new Error('TODO: getAppFactory is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide app client lookup by creator and name after porting legacy algokit-utils-ts. + */ + async getAppClientByCreatorAndName(_params: unknown): Promise { + throw new Error('TODO: getAppClientByCreatorAndName is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide app client lookup by ID after porting legacy algokit-utils-ts. + */ + getAppClientById(_params: unknown): never { + throw new Error('TODO: getAppClientById is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide app client lookup by network after porting legacy algokit-utils-ts. + */ + async getAppClientByNetwork(_params: unknown): Promise { + throw new Error('TODO: getAppClientByNetwork is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide typed app client lookup after porting legacy algokit-utils-ts. + */ + async getTypedAppClientByCreatorAndName(_typedClient: unknown, _params: unknown): Promise { + throw new Error('TODO: getTypedAppClientByCreatorAndName is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide typed app client lookup by ID after porting legacy algokit-utils-ts. + */ + getTypedAppClientById(_typedClient: unknown, _params: unknown): never { + throw new Error('TODO: getTypedAppClientById is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide typed app client lookup by network after porting legacy algokit-utils-ts. + */ + async getTypedAppClientByNetwork(_typedClient: unknown, _params?: unknown): Promise { + throw new Error('TODO: getTypedAppClientByNetwork is not yet implemented in the TypeScript ClientManager') + } + + /** + * TODO: Provide typed app factory construction after porting legacy algokit-utils-ts. + */ + getTypedAppFactory(_typedFactory: unknown, _params?: unknown): never { + throw new Error('TODO: getTypedAppFactory is not yet implemented in the TypeScript ClientManager') + } + /** * Derive configuration from the environment if possible, otherwise default to a localnet configuration. */ static getConfigFromEnvironmentOrLocalNet(): AlgoConfig { - try { - const algodConfig = this.getAlgodConfigFromEnvironment() - const indexerConfig = this.safeGetConfig(this.getIndexerConfigFromEnvironment.bind(this)) - - const isPublicNetwork = /mainnet|testnet/.test(algodConfig.server) - const kmdConfig = isPublicNetwork - ? undefined - : this.safeGetConfig(() => this.getKmdConfigFromEnvironment(algodConfig)) ?? { - server: algodConfig.server, - port: this.parsePort(process.env.KMD_PORT) ?? this.parsePort(process.env.ALGOD_PORT) ?? 4002, - token: process.env.KMD_TOKEN ?? process.env.ALGOD_TOKEN, - } + if (!process || !process.env) { + throw new Error('Attempt to get default client configuration from a non Node.js context; supply the config instead') + } + const [algodConfig, indexerConfig, kmdConfig] = process.env.ALGOD_SERVER + ? [ + ClientManager.getAlgodConfigFromEnvironment(), + process.env.INDEXER_SERVER ? ClientManager.getIndexerConfigFromEnvironment() : undefined, + !process.env.ALGOD_SERVER.includes('mainnet') && !process.env.ALGOD_SERVER.includes('testnet') + ? { ...ClientManager.getAlgodConfigFromEnvironment(), port: process?.env?.KMD_PORT ?? '4002' } + : undefined, + ] + : [ + ClientManager.getDefaultLocalNetConfig(AlgorandService.Algod), + ClientManager.getDefaultLocalNetConfig(AlgorandService.Indexer), + ClientManager.getDefaultLocalNetConfig(AlgorandService.Kmd), + ] - return { - algodConfig, - indexerConfig, - kmdConfig, - } - } catch (error) { - return { - algodConfig: this.getDefaultLocalnetConfig(AlgorandService.Algod), - indexerConfig: this.getDefaultLocalnetConfig(AlgorandService.Indexer), - kmdConfig: this.getDefaultLocalnetConfig(AlgorandService.Kmd), - } + return { + algodConfig, + indexerConfig, + kmdConfig, } } @@ -157,7 +250,9 @@ export class ClientManager { static getIndexerConfigFromEnvironment(): AlgoClientConfig { const server = process.env.INDEXER_SERVER if (!server) { - throw new Error('INDEXER_SERVER environment variable not found') + throw new ClientManagerError('ENVIRONMENT_MISSING', 'INDEXER_SERVER environment variable not found', { + variable: 'INDEXER_SERVER', + }) } const port = this.parsePort(process.env.INDEXER_PORT) @@ -177,7 +272,9 @@ export class ClientManager { static getAlgodConfigFromEnvironment(): AlgoClientConfig { const server = process.env.ALGOD_SERVER if (!server) { - throw new Error('ALGOD_SERVER environment variable not found') + throw new ClientManagerError('ENVIRONMENT_MISSING', 'ALGOD_SERVER environment variable not found', { + variable: 'ALGOD_SERVER', + }) } const port = this.parsePort(process.env.ALGOD_PORT) @@ -198,13 +295,12 @@ export class ClientManager { static getKmdConfigFromEnvironment(fallbackAlgodConfig?: AlgoClientConfig): AlgoClientConfig { const server = process.env.KMD_SERVER ?? fallbackAlgodConfig?.server ?? process.env.ALGOD_SERVER if (!server) { - throw new Error('KMD_SERVER environment variable not found') + throw new ClientManagerError('ENVIRONMENT_MISSING', 'KMD_SERVER environment variable not found', { + variable: 'KMD_SERVER', + }) } - const port = this.parsePort(process.env.KMD_PORT) - ?? fallbackAlgodConfig?.port - ?? this.parsePort(process.env.ALGOD_PORT) - ?? 4002 + const port = this.parsePort(process.env.KMD_PORT) ?? fallbackAlgodConfig?.port ?? this.parsePort(process.env.ALGOD_PORT) ?? 4002 const token = process.env.KMD_TOKEN ?? process.env.ALGOD_TOKEN @@ -215,10 +311,17 @@ export class ClientManager { } } - /** - * Returns an Algonode configuration for the provided network and service. + /** Returns the Algorand configuration to point to the free tier of the AlgoNode service. + * + * @param network Which network to connect to - TestNet or MainNet + * @param config Which algod config to return - Algod or Indexer + * @returns The AlgoNode client configuration + * @example + * ```typescript + * const config = ClientManager.getAlgoNodeConfig('testnet', 'algod') + * ``` */ - static getAlgonodeConfig(network: string, service: AlgorandService): AlgoClientConfig { + static getAlgoNodeConfig(network: string, service: AlgorandService): AlgoClientConfig { if (service === AlgorandService.Kmd) { throw new Error('KMD is not available on algonode') } @@ -231,15 +334,26 @@ export class ClientManager { } } - /** - * Returns a default localnet configuration for the provided service. + /** Returns the Algorand configuration to point to the default LocalNet. + * + * @param configOrPort Which algod config to return - algod, kmd, or indexer OR a port number + * @returns The LocalNet client configuration + * @example + * ```typescript + * const config = ClientManager.getDefaultLocalNetConfig('algod') + * ``` */ - static getDefaultLocalnetConfig(service: AlgorandService): AlgoClientConfig { - const port = service === AlgorandService.Algod ? 4001 : service === AlgorandService.Indexer ? 8980 : 4002 - + public static getDefaultLocalNetConfig(configOrPort: AlgorandService | number): AlgoClientConfig { return { - server: 'http://localhost', - port, + server: `http://localhost`, + port: + configOrPort === AlgorandService.Algod + ? 4001 + : configOrPort === AlgorandService.Indexer + ? 8980 + : configOrPort === AlgorandService.Kmd + ? 4002 + : configOrPort, token: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', } } @@ -248,24 +362,24 @@ export class ClientManager { * Creates an Algod client for the given configuration. */ static getAlgodClient(config: AlgoClientConfig): AlgodClient { - const clientConfig = this.createHttpClientConfig(config, 'X-Algo-API-Token') - return new AlgodClient(clientConfig) + const { clientConfig, request } = this.createHttpClientComponents(config, 'X-Algo-API-Token') + return new AlgodClient(clientConfig, request) } /** * Creates an Indexer client for the given configuration. */ static getIndexerClient(config: AlgoClientConfig): IndexerClient { - const clientConfig = this.createHttpClientConfig(config, 'X-Indexer-API-Token') - return new IndexerClient(clientConfig) + const { clientConfig, request } = this.createHttpClientComponents(config, 'X-Indexer-API-Token') + return new IndexerClient(clientConfig, request) } /** * Creates a KMD client for the given configuration. */ static getKmdClient(config: AlgoClientConfig): KmdClient { - const clientConfig = this.createHttpClientConfig(config, 'X-KMD-API-Token') - return new KmdClient(clientConfig) + const { clientConfig, request } = this.createHttpClientComponents(config, 'X-KMD-API-Token') + return new KmdClient(clientConfig, request) } /** @@ -297,8 +411,8 @@ export class ClientManager { const genesisHash = Buffer.from(params.genesisHash ?? new Uint8Array()).toString('base64') return { - isTestnet: genesisId === 'testnet-v1.0', - isMainnet: genesisId === 'mainnet-v1.0', + isTestnet: genesisIdIsTestnet(genesisId), + isMainnet: genesisIdIsMainnet(genesisId), isLocalnet: genesisIdIsLocalNet(genesisId), genesisId, genesisHash, @@ -311,12 +425,30 @@ export class ClientManager { } } - private static createHttpClientConfig(config: AlgoClientConfig, defaultHeaderName: string) { + private static createHttpClientComponents(config: AlgoClientConfig, defaultHeaderName: string): HttpClientFactoryResult { + if (this.httpClientFactory) { + return this.httpClientFactory(config, defaultHeaderName) + } + const clientConfig = this.buildHttpClientConfig(config, defaultHeaderName) + return { + clientConfig, + request: new RetryHttpRequest(clientConfig), + } + } + + private static buildHttpClientConfig(config: AlgoClientConfig, defaultHeaderName: string): HttpClientConfig { const baseUrl = this.buildBaseUrl(config) const headers = this.buildHeaders(config.token, defaultHeaderName) return headers ? { baseUrl, headers } : { baseUrl } } + /** + * Configure a custom HTTP client factory, e.g. to integrate the retry-enabled HTTP layer. + */ + static configureHttpClientFactory(factory: HttpClientFactory | undefined): void { + this.httpClientFactory = factory + } + private static buildHeaders(token: TokenHeader | undefined, defaultHeaderName: string): Record | undefined { if (!token) { return undefined diff --git a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts new file mode 100644 index 000000000..a05f9f9b8 --- /dev/null +++ b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts @@ -0,0 +1,225 @@ +import { + ApiError, + type ApiRequestOptions, + BaseHttpRequest, + decodeMsgPack, + encodeMsgPack, + type ClientConfig, +} from '@algorandfoundation/algod-client' + +const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] +const RETRY_ERROR_CODES = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN', + 'EPROTO', +] + +const DEFAULT_MAX_TRIES = 5 +const DEFAULT_MAX_BACKOFF_MS = 10_000 + +const encodeURIPath = (path: string): string => encodeURI(path).replace(/%5B/g, '[').replace(/%5D/g, ']') + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value + } + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +const extractStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { status?: unknown; response?: { status?: unknown } } + return toNumber(candidate.status ?? candidate.response?.status) +} + +const extractCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { code?: unknown; cause?: { code?: unknown } } + const raw = candidate.code ?? candidate.cause?.code + return typeof raw === 'string' ? raw : undefined +} + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +export interface RetryHttpRequestOptions { + maxTries?: number + maxBackoffMs?: number +} + +export class RetryHttpRequest extends BaseHttpRequest { + private readonly maxTries: number + private readonly maxBackoffMs: number + + constructor(config: ClientConfig, options?: RetryHttpRequestOptions) { + super(config) + this.maxTries = options?.maxTries ?? DEFAULT_MAX_TRIES + this.maxBackoffMs = options?.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS + } + + async request(options: ApiRequestOptions): Promise { + let attempt = 1 + let lastError: unknown + while (attempt <= this.maxTries) { + try { + return await this.execute(options) + } catch (error) { + lastError = error + if (!this.shouldRetry(error, attempt)) { + throw error + } + + const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), this.maxBackoffMs) + if (backoff > 0) { + // eslint-disable-next-line no-console + console.warn(`Retrying request after ${backoff}ms due to error:`, error) + await delay(backoff) + } + attempt += 1 + } + } + + throw lastError ?? new Error('Request failed after exhausting retries') + } + + private shouldRetry(error: unknown, attempt: number): boolean { + if (attempt >= this.maxTries) { + return false + } + + const status = extractStatus(error) + if (status !== undefined && RETRY_STATUS_CODES.includes(status)) { + return true + } + + const code = extractCode(error) + if (code && RETRY_ERROR_CODES.includes(code)) { + return true + } + + return false + } + + private async execute(options: ApiRequestOptions): Promise { + let rawPath = options.url + if (options.path) { + for (const [key, value] of Object.entries(options.path)) { + const serialized = typeof value === 'bigint' ? value.toString() : String(value) + const encoded = this.config.encodePath ? this.config.encodePath(serialized) : encodeURIPath(serialized) + rawPath = rawPath.replace(`{${key}}`, encoded) + } + } + + const url = new URL(rawPath, this.config.baseUrl) + + if (options.query) { + for (const [key, value] of Object.entries(options.query)) { + if (value === undefined || value === null) continue + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, item.toString()) + } + } else { + url.searchParams.append(key, value.toString()) + } + } + } + + const headers: Record = { + ...(typeof this.config.headers === 'function' ? await this.config.headers() : this.config.headers ?? {}), + ...(options.headers ?? {}), + } + + if (this.config.apiToken) { + headers['X-Algo-API-Token'] = this.config.apiToken + } + + const token = typeof this.config.token === 'function' ? await this.config.token() : this.config.token + if (token) { + headers['Authorization'] = `Bearer ${token}` + } else if (this.config.username && this.config.password) { + headers['Authorization'] = `Basic ${btoa(`${this.config.username}:${this.config.password}`)}` + } + + let payload: BodyInit | undefined + if (options.body != null) { + const { body } = options + if (body instanceof Uint8Array) { + payload = body + } else if (typeof body === 'string') { + payload = body + } else if (options.mediaType?.includes('msgpack')) { + payload = encodeMsgPack(body) + } else if (options.mediaType?.includes('json')) { + payload = JSON.stringify(body) + } else { + payload = JSON.stringify(body) + } + } + + const response = await fetch(url.toString(), { + method: options.method, + headers, + body: payload, + credentials: this.config.credentials, + }) + + if (!response.ok) { + let errorBody: unknown + try { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/msgpack')) { + errorBody = decodeMsgPack(new Uint8Array(await response.arrayBuffer())) + } else if (contentType.includes('application/json')) { + errorBody = JSON.parse(await response.text()) + } else { + errorBody = await response.text() + } + } catch { + errorBody = undefined + } + throw new ApiError(url.toString(), response.status, errorBody) + } + + if (options.responseHeader) { + const value = response.headers.get(options.responseHeader) + return value as unknown as T + } + + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('application/msgpack')) { + return new Uint8Array(await response.arrayBuffer()) as unknown as T + } + + if (contentType.includes('application/octet-stream') || contentType.includes('application/x-binary')) { + return new Uint8Array(await response.arrayBuffer()) as unknown as T + } + + if (contentType.includes('application/json')) { + return (await response.text()) as unknown as T + } + + if (!contentType) { + return new Uint8Array(await response.arrayBuffer()) as unknown as T + } + + return (await response.text()) as unknown as T + } +} diff --git a/packages/typescript/algokit_utils/src/index.ts b/packages/typescript/algokit_utils/src/index.ts index a76a4ba32..45f299141 100644 --- a/packages/typescript/algokit_utils/src/index.ts +++ b/packages/typescript/algokit_utils/src/index.ts @@ -3,5 +3,6 @@ export * from './clients/asset-manager' export * from './clients/client-manager' export * from './clients/app-manager' export * from './clients/network-client' +export * from './clients/http/retry-http-request' export * from '@algorandfoundation/algokit-transact' From 2a81f847bfeb90e43281335c5290a0c5e705729d Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:31:51 +0200 Subject: [PATCH 07/18] chore: prettier --- .../algokit_utils/src/algorand-client.ts | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/typescript/algokit_utils/src/algorand-client.ts b/packages/typescript/algokit_utils/src/algorand-client.ts index f431879e2..1f06d8e30 100644 --- a/packages/typescript/algokit_utils/src/algorand-client.ts +++ b/packages/typescript/algokit_utils/src/algorand-client.ts @@ -2,11 +2,7 @@ import { AppManager } from './clients/app-manager' import { AssetManager } from './clients/asset-manager' import { ClientManager } from './clients/client-manager' import { AlgoConfig, AlgorandService } from './clients/network-client' -import { - TransactionComposer, - TransactionComposerConfig, - TransactionComposerParams, -} from './transactions/composer' +import { TransactionComposer, TransactionComposerConfig, TransactionComposerParams } from './transactions/composer' import { SignerGetter, TransactionSigner } from './transactions/common' import type { TransactionParams } from '@algorandfoundation/algod-client' @@ -139,29 +135,38 @@ export class AlgorandClient { /** Creates an AlgorandClient configured for a local development network. */ static localnet(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig({ - algodConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Algod), - indexerConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Indexer), - kmdConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Kmd), - }, composerConfig) + return AlgorandClient.fromConfig( + { + algodConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Algod), + indexerConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Indexer), + kmdConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Kmd), + }, + composerConfig, + ) } /** Creates an AlgorandClient configured for Algonode TestNet. */ static testnet(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig({ - algodConfig: ClientManager.getAlgonodeConfig('testnet', AlgorandService.Algod), - indexerConfig: ClientManager.getAlgonodeConfig('testnet', AlgorandService.Indexer), - kmdConfig: undefined, - }, composerConfig) + return AlgorandClient.fromConfig( + { + algodConfig: ClientManager.getAlgoNodeConfig('testnet', AlgorandService.Algod), + indexerConfig: ClientManager.getAlgoNodeConfig('testnet', AlgorandService.Indexer), + kmdConfig: undefined, + }, + composerConfig, + ) } /** Creates an AlgorandClient configured for Algonode MainNet. */ static mainnet(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig({ - algodConfig: ClientManager.getAlgonodeConfig('mainnet', AlgorandService.Algod), - indexerConfig: ClientManager.getAlgonodeConfig('mainnet', AlgorandService.Indexer), - kmdConfig: undefined, - }, composerConfig) + return AlgorandClient.fromConfig( + { + algodConfig: ClientManager.getAlgoNodeConfig('mainnet', AlgorandService.Algod), + indexerConfig: ClientManager.getAlgoNodeConfig('mainnet', AlgorandService.Indexer), + kmdConfig: undefined, + }, + composerConfig, + ) } /** Creates an AlgorandClient from environment configuration or defaults to localnet. */ From 8ef33c2cc714a8f272bea1a92fd04b5fc0b13393 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:43:20 +0200 Subject: [PATCH 08/18] fix: bult opt out rust test --- .../tests/clients/asset_manager.rs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/algokit_utils/tests/clients/asset_manager.rs b/crates/algokit_utils/tests/clients/asset_manager.rs index 91362be20..5915f3529 100644 --- a/crates/algokit_utils/tests/clients/asset_manager.rs +++ b/crates/algokit_utils/tests/clients/asset_manager.rs @@ -313,17 +313,19 @@ async fn test_bulk_opt_out_batches( let asset_manager = algorand_fixture.algorand_client.asset(); - let mut composer = algorand_fixture.algorand_client.new_composer(None); - for &asset_id in &asset_ids { - let opt_in_params = AssetOptInParams { - sender: test_address.clone(), - signer: Some(Arc::new(test_account.clone())), - asset_id, - ..Default::default() - }; - composer.add_asset_opt_in(opt_in_params)?; + for asset_chunk in asset_ids.chunks(MAX_TX_GROUP_SIZE) { + let mut composer = algorand_fixture.algorand_client.new_composer(None); + for &asset_id in asset_chunk { + let opt_in_params = AssetOptInParams { + sender: test_address.clone(), + signer: Some(Arc::new(test_account.clone())), + asset_id, + ..Default::default() + }; + composer.add_asset_opt_in(opt_in_params)?; + } + composer.send(Default::default()).await?; } - composer.send(Default::default()).await?; let results = asset_manager .bulk_opt_out(&test_address, &asset_ids, None) From 7f2722db4ade2f4e9580b2011ec7a084ec8c6114 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:46:58 +0200 Subject: [PATCH 09/18] chore: revert algorand changes (out of pr scope) --- .../algokit_utils/src/algorand-client.ts | 227 ++++++------------ 1 file changed, 71 insertions(+), 156 deletions(-) diff --git a/packages/typescript/algokit_utils/src/algorand-client.ts b/packages/typescript/algokit_utils/src/algorand-client.ts index 1f06d8e30..c205d9b67 100644 --- a/packages/typescript/algokit_utils/src/algorand-client.ts +++ b/packages/typescript/algokit_utils/src/algorand-client.ts @@ -1,176 +1,91 @@ -import { AppManager } from './clients/app-manager' -import { AssetManager } from './clients/asset-manager' -import { ClientManager } from './clients/client-manager' -import { AlgoConfig, AlgorandService } from './clients/network-client' -import { TransactionComposer, TransactionComposerConfig, TransactionComposerParams } from './transactions/composer' -import { SignerGetter, TransactionSigner } from './transactions/common' -import type { TransactionParams } from '@algorandfoundation/algod-client' +import { AlgoConfig } from './clients/network-client' +import { TransactionComposerConfig } from './transactions/composer' +import type { AlgodClient } from '@algorandfoundation/algod-client' -export type AlgorandClientParams = - | { - clientConfig: AlgoConfig - clientManager?: undefined - composerConfig?: TransactionComposerConfig - } - | { - clientConfig?: undefined - clientManager: ClientManager - composerConfig?: TransactionComposerConfig - } - -class SignerRegistry implements SignerGetter { - private readonly signers = new Map() - private defaultSigner?: TransactionSigner - - setSigner(address: string, signer: TransactionSigner) { - this.signers.set(address, signer) - } - - clearSigner(address: string) { - this.signers.delete(address) - } +export type AlgorandClientParams = { + clientConfig: AlgoConfig + composerConfig?: TransactionComposerConfig +} - setDefaultSigner(signer: TransactionSigner) { - this.defaultSigner = signer - } +export type PaymentParams = { + sender: string + receiver: string + amount: bigint +} - clearDefaultSigner() { - this.defaultSigner = undefined - } +export type AssetConfigParams = { + sender: string + total: bigint + decimals: number + defaultFrozen: boolean + assetName: string + unitName: string + manager: string + reserve: string + freeze: string + clawback: string +} - getSigner(address: string): TransactionSigner { - const signer = this.signers.get(address) ?? this.defaultSigner - if (!signer) { - throw new Error(`No signer registered for address ${address}. Use setSigner or setDefaultSigner to configure one.`) - } - return signer - } +export type AppCreateParams = { + sender: string + approvalProgram: string + clearStateProgram: string + globalStateSchema: { numUints: number; numByteSlices: number } + localStateSchema: { numUints: number; numByteSlices: number } } /** * A client that brokers easy access to Algorand functionality. */ export class AlgorandClient { - private readonly clientManager: ClientManager - private readonly assetManager: AssetManager - private readonly appManager: AppManager - private readonly signerRegistry = new SignerRegistry() - private readonly defaultComposerConfig?: TransactionComposerConfig + private composerConfig?: TransactionComposerConfig constructor(params: AlgorandClientParams) { - if (!params.clientManager && !params.clientConfig) { - throw new Error('AlgorandClient requires either a client configuration or an existing ClientManager instance.') + this.composerConfig = params.composerConfig + } + + /** + * Creates a new transaction group + */ + newComposer(composerConfig?: TransactionComposerConfig) { + // For testing purposes, return a mock transaction composer + const self = this + return { + addPayment: (params: PaymentParams) => self.newComposer(composerConfig), + addAssetConfig: (params: AssetConfigParams) => self.newComposer(composerConfig), + addAppCreate: (params: AppCreateParams) => self.newComposer(composerConfig), + send: async () => ({ + confirmations: [ + { + txn: { id: `mock-tx-id-${Math.random().toString(36).substr(2, 9)}` }, + appId: Math.floor(Math.random() * 10000), + assetId: Math.floor(Math.random() * 10000), + }, + ], + }), } - - this.clientManager = params.clientManager ?? new ClientManager(params.clientConfig) - this.defaultComposerConfig = params.composerConfig - - this.assetManager = new AssetManager(this.clientManager.algod, () => this.newComposer()) - this.appManager = new AppManager(this.clientManager.algod) } - /** Creates a new transaction composer pre-configured with the Algorand client context. */ - newComposer(composerConfig?: TransactionComposerConfig): TransactionComposer { - const params: TransactionComposerParams = { - algodClient: this.clientManager.algod, - signerGetter: this.signerRegistry, - composerConfig: composerConfig ?? this.defaultComposerConfig, + /** + * Send operations namespace + */ + get send() { + return { + payment: async (params: PaymentParams) => ({ + confirmations: [ + { + txn: { id: `mock-payment-tx-${Math.random().toString(36).substr(2, 9)}` }, + }, + ], + }), } - return new TransactionComposer(params) - } - - /** Registers a signer for a specific address. */ - setSigner(address: string, signer: TransactionSigner): this { - this.signerRegistry.setSigner(address, signer) - return this - } - - /** Removes a previously registered signer for an address. */ - clearSigner(address: string): this { - this.signerRegistry.clearSigner(address) - return this - } - - /** Registers a default signer used when no address-specific signer exists. */ - setDefaultSigner(signer: TransactionSigner): this { - this.signerRegistry.setDefaultSigner(signer) - return this - } - - /** Clears the default signer. */ - clearDefaultSigner(): this { - this.signerRegistry.clearDefaultSigner() - return this - } - - /** Returns the underlying ClientManager. */ - get client(): ClientManager { - return this.clientManager - } - - /** Returns the AssetManager helper. */ - get asset(): AssetManager { - return this.assetManager - } - - /** Returns the AppManager helper. */ - get app(): AppManager { - return this.appManager - } - - /** Retrieves suggested transaction parameters from algod. */ - async getSuggestedParams(): Promise { - return await this.clientManager.algod.transactionParams() - } - - /** Creates an AlgorandClient from a raw network configuration. */ - static fromConfig(clientConfig: AlgoConfig, composerConfig?: TransactionComposerConfig): AlgorandClient { - return new AlgorandClient({ clientConfig, composerConfig }) - } - - /** Creates an AlgorandClient from an existing ClientManager. */ - static fromClientManager(clientManager: ClientManager, composerConfig?: TransactionComposerConfig): AlgorandClient { - return new AlgorandClient({ clientManager, composerConfig }) - } - - /** Creates an AlgorandClient configured for a local development network. */ - static localnet(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig( - { - algodConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Algod), - indexerConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Indexer), - kmdConfig: ClientManager.getDefaultLocalnetConfig(AlgorandService.Kmd), - }, - composerConfig, - ) - } - - /** Creates an AlgorandClient configured for Algonode TestNet. */ - static testnet(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig( - { - algodConfig: ClientManager.getAlgoNodeConfig('testnet', AlgorandService.Algod), - indexerConfig: ClientManager.getAlgoNodeConfig('testnet', AlgorandService.Indexer), - kmdConfig: undefined, - }, - composerConfig, - ) - } - - /** Creates an AlgorandClient configured for Algonode MainNet. */ - static mainnet(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig( - { - algodConfig: ClientManager.getAlgoNodeConfig('mainnet', AlgorandService.Algod), - indexerConfig: ClientManager.getAlgoNodeConfig('mainnet', AlgorandService.Indexer), - kmdConfig: undefined, - }, - composerConfig, - ) } - /** Creates an AlgorandClient from environment configuration or defaults to localnet. */ - static fromEnvironment(composerConfig?: TransactionComposerConfig): AlgorandClient { - return AlgorandClient.fromConfig(ClientManager.getConfigFromEnvironmentOrLocalNet(), composerConfig) + /** + * Set a signer for an address + */ + setSigner(address: string, signer: any): void { + // For testing purposes, just store the signer reference + console.log(`Setting signer for address ${address}`) } } From a8d019659f61363ffeb526d23616c074748465a1 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 14 Oct 2025 19:49:41 +0200 Subject: [PATCH 10/18] chore: typo --- .../algokit_utils/src/clients/asset-manager.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/typescript/algokit_utils/src/clients/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index c3f9db122..5a0542058 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -199,13 +199,10 @@ export class AssetManager { clawback: asset.params.clawback, unitName: asset.params.unitName, unitNameAsBytes: asset.params.unitNameB64, - unitNameB64: asset.params.unitNameB64, assetName: asset.params.name, assetNameAsBytes: asset.params.nameB64, - assetNameB64: asset.params.nameB64, url: asset.params.url, urlAsBytes: asset.params.urlB64, - urlB64: asset.params.urlB64, metadataHash: asset.params.metadataHash, } } catch (error) { @@ -249,10 +246,9 @@ export class AssetManager { return [] } - const normalizedAssetIds = assetIds.map((assetId) => BigInt(assetId)) const results: BulkAssetOptInOutResult[] = [] - for (const batch of chunkArray(normalizedAssetIds, MAX_TX_GROUP_SIZE)) { + for (const batch of chunkArray(assetIds, MAX_TX_GROUP_SIZE)) { const composer = this.newComposer() for (const assetId of batch) { @@ -300,12 +296,11 @@ export class AssetManager { return [] } - const normalizedAssetIds = assetIds.map((assetId) => BigInt(assetId)) const shouldCheckBalance = ensureZeroBalance ?? false const results: BulkAssetOptInOutResult[] = [] if (shouldCheckBalance) { - for (const assetId of normalizedAssetIds) { + for (const assetId of assetIds) { const accountInfo = await this.getAccountInformation(account, assetId).catch((error: unknown) => { if (error instanceof AssetManagerError && error.code === 'NOT_OPTED_IN') { throw new AssetManagerError( @@ -334,7 +329,7 @@ export class AssetManager { const creatorCache = new Map() - for (const batch of chunkArray(normalizedAssetIds, MAX_TX_GROUP_SIZE)) { + for (const batch of chunkArray(assetIds, MAX_TX_GROUP_SIZE)) { const composer = this.newComposer() const creators: string[] = [] From eef3f86a53c3d4c05be2eafd3ae9d937c0eec4de Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 16 Oct 2025 02:18:03 +0200 Subject: [PATCH 11/18] chore: tests wip --- .../src/transactions/composer.ts | 23 +- .../tests/clients/asset-manager.test.ts | 219 ++++++++++++++++++ .../tests/clients/client-manager.test.ts | 45 ++++ .../algokit_utils/tests/clients/fixtures.ts | 152 ++++++++++++ .../tests/clients/network-client.test.ts | 22 ++ 5 files changed, 448 insertions(+), 13 deletions(-) create mode 100644 packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts create mode 100644 packages/typescript/algokit_utils/tests/clients/client-manager.test.ts create mode 100644 packages/typescript/algokit_utils/tests/clients/fixtures.ts create mode 100644 packages/typescript/algokit_utils/tests/clients/network-client.test.ts diff --git a/packages/typescript/algokit_utils/src/transactions/composer.ts b/packages/typescript/algokit_utils/src/transactions/composer.ts index a58fb73ed..e1a735cfd 100644 --- a/packages/typescript/algokit_utils/src/transactions/composer.ts +++ b/packages/typescript/algokit_utils/src/transactions/composer.ts @@ -1,5 +1,4 @@ // TODO: Once all the abstractions and http clients have been implement, then this should be removed. -/* eslint-disable @typescript-eslint/no-explicit-any */ /** * Transaction composer implementation based on the Rust AlgoKit Core composer. * This provides a clean interface for building and executing transaction groups. @@ -24,6 +23,7 @@ import { getTransactionId, groupTransactions, } from '@algorandfoundation/algokit-transact' +import { AlgodClient, ApiError } from '@algorandfoundation/algod-client' import { genesisIdIsLocalNet } from '../clients/network-client' import { ApplicationLocalReference, @@ -174,7 +174,7 @@ export type SendTransactionComposerResults = { } export type TransactionComposerParams = { - algodClient: any + algodClient: AlgodClient signerGetter: SignerGetter composerConfig?: TransactionComposerConfig } @@ -185,7 +185,7 @@ export type TransactionComposerConfig = { } export class TransactionComposer { - private algodClient: any // TODO: Replace with client once implemented + private algodClient: AlgodClient private signerGetter: SignerGetter private composerConfig: TransactionComposerConfig @@ -348,7 +348,7 @@ export class TransactionComposer { private async getSuggestedParams(): Promise { // TODO: Add caching with expiration - return await this.algodClient.getTransactionParams() + return await this.algodClient.transactionParams() } private buildTransactionHeader( @@ -465,9 +465,7 @@ export class TransactionComposer { transaction = buildNonParticipationKeyRegistration(ctxn.data, header) break default: - // This should never happen if all cases are covered - - throw new Error(`Unsupported transaction type: ${(ctxn as any).type}`) + throw new Error('Unsupported transaction type encountered while building transaction group') } if (calculateFee) { @@ -802,7 +800,7 @@ export class TransactionComposer { const encodedTxns = encodeSignedTransactions(this.signedGroup) const encodedBytes = concatArrays(...encodedTxns) - await this.algodClient.rawTransaction(encodedBytes) + await this.algodClient.rawTransaction({ body: encodedBytes }) const transactions = this.signedGroup.map((stxn) => stxn.transaction) const transactionIds = transactions.map((txn) => getTransactionId(txn)) @@ -838,8 +836,8 @@ export class TransactionComposer { } private async waitForConfirmation(txId: string, maxRoundsToWait: number): Promise { - const status = await this.algodClient.status().do() - const startRound = status.lastRound + 1 + const status = await this.algodClient.getStatus() + const startRound = status.lastRound + 1n let currentRound = startRound while (currentRound < startRound + BigInt(maxRoundsToWait)) { try { @@ -855,13 +853,12 @@ export class TransactionComposer { } } } catch (e: unknown) { - // TODO: Handle the 404 correctly once algod client is build - if (e instanceof Error && e.message.includes('404')) { + if (e instanceof ApiError && e.status === 404) { currentRound++ continue } } - await this.algodClient.statusAfterBlock(currentRound) + await this.algodClient.waitForBlock(currentRound) currentRound++ } diff --git a/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts b/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts new file mode 100644 index 000000000..6e097a296 --- /dev/null +++ b/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts @@ -0,0 +1,219 @@ +import { MAX_TX_GROUP_SIZE } from '@algorandfoundation/algokit-common' +import { describe, expect, it } from 'vitest' +import { createAssetTestContext, createFundedAccount, createTestAsset, transferAsset } from './fixtures' + +const TEST_TIMEOUT = 120_000 + +describe.sequential('AssetManager integration', () => { + it( + 'retrieves asset information by id', + async () => { + const context = await createAssetTestContext() + const assetId = await createTestAsset(context, { assetName: 'AssetManager E2E', unitName: 'AME2E' }) + + const info = await context.assetManager.getById(assetId) + + expect(info.assetId).toBe(assetId) + expect(info.creator).toBe(context.creator.address) + expect(info.total).toBe(1_000n) + expect(info.decimals).toBe(0) + expect(info.assetName).toBe('AssetManager E2E') + expect(info.unitName).toBe('AME2E') + }, + TEST_TIMEOUT, + ) + + it( + 'maps missing assets to ASSET_NOT_FOUND errors', + async () => { + const context = await createAssetTestContext() + + await expect(context.assetManager.getById(9_999_999_999n)).rejects.toMatchObject({ + code: 'ASSET_NOT_FOUND', + }) + }, + TEST_TIMEOUT, + ) + + it( + 'retrieves account holdings for opted-in creator', + async () => { + const context = await createAssetTestContext() + const assetId = await createTestAsset(context) + + const accountInfo = await context.assetManager.getAccountInformation(context.creator.address, assetId) + + expect(accountInfo.assetHolding?.assetId).toBe(assetId) + expect(accountInfo.assetHolding?.amount).toBe(1_000n) + }, + TEST_TIMEOUT, + ) + + it( + 'raises NOT_OPTED_IN when account has not opted in', + async () => { + const context = await createAssetTestContext() + const assetId = await createTestAsset(context) + const account = await createFundedAccount(context) + + await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ + code: 'NOT_OPTED_IN', + }) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt in opts into each requested asset', + async () => { + const context = await createAssetTestContext() + const assets = await Promise.all([createTestAsset(context), createTestAsset(context)]) + const account = await createFundedAccount(context) + + const results = await context.assetManager.bulkOptIn(account.address, assets) + + expect(results).toHaveLength(assets.length) + for (const [index, result] of results.entries()) { + expect(result.assetId).toBe(assets[index]) + const info = await context.assetManager.getAccountInformation(account.address, assets[index]) + expect(info.assetHolding?.assetId).toBe(assets[index]) + expect(info.assetHolding?.amount).toBe(0n) + } + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt in splits batches above the max group size', + async () => { + const context = await createAssetTestContext() + const assetCount = MAX_TX_GROUP_SIZE + 3 + const assetIds: bigint[] = [] + for (let i = 0; i < assetCount; i++) { + assetIds.push(await createTestAsset(context)) + } + const account = await createFundedAccount(context) + + const results = await context.assetManager.bulkOptIn(account.address, assetIds) + + expect(results).toHaveLength(assetCount) + expect(results.map((r) => r.assetId)).toEqual(assetIds) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt in returns an empty collection when no assets provided', + async () => { + const context = await createAssetTestContext() + const account = await createFundedAccount(context) + + const results = await context.assetManager.bulkOptIn(account.address, []) + + expect(results).toEqual([]) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out removes holdings and closes to the creator', + async () => { + const context = await createAssetTestContext() + const assetIds = await Promise.all([createTestAsset(context), createTestAsset(context)]) + const account = await createFundedAccount(context) + + await context.assetManager.bulkOptIn(account.address, assetIds) + + const results = await context.assetManager.bulkOptOut(account.address, assetIds, true) + + expect(results).toHaveLength(assetIds.length) + for (const assetId of assetIds) { + await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ + code: 'NOT_OPTED_IN', + }) + } + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out splits batches appropriately', + async () => { + const context = await createAssetTestContext() + const assetCount = MAX_TX_GROUP_SIZE + 2 + const assetIds: bigint[] = [] + for (let i = 0; i < assetCount; i++) { + assetIds.push(await createTestAsset(context)) + } + const account = await createFundedAccount(context) + + await context.assetManager.bulkOptIn(account.address, assetIds) + + const results = await context.assetManager.bulkOptOut(account.address, assetIds, true) + + expect(results).toHaveLength(assetCount) + expect(results.map((r) => r.assetId)).toEqual(assetIds) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out returns an empty collection for empty requests', + async () => { + const context = await createAssetTestContext() + const account = await createFundedAccount(context) + + const results = await context.assetManager.bulkOptOut(account.address, [], true) + + expect(results).toEqual([]) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out rejects when balance check detects non-zero balance', + async () => { + const context = await createAssetTestContext() + const assetId = await createTestAsset(context) + const account = await createFundedAccount(context) + + await context.assetManager.bulkOptIn(account.address, [assetId]) + await transferAsset(context, { + sender: context.creator.address, + receiver: account.address, + amount: 10n, + assetId, + }) + + await expect(context.assetManager.bulkOptOut(account.address, [assetId], true)).rejects.toMatchObject({ + code: 'NON_ZERO_BALANCE', + }) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out can override the balance check and close out remaining balance', + async () => { + const context = await createAssetTestContext() + const assetId = await createTestAsset(context) + const account = await createFundedAccount(context) + + await context.assetManager.bulkOptIn(account.address, [assetId]) + await transferAsset(context, { + sender: context.creator.address, + receiver: account.address, + amount: 5n, + assetId, + }) + + const results = await context.assetManager.bulkOptOut(account.address, [assetId], false) + + expect(results).toHaveLength(1) + await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ + code: 'NOT_OPTED_IN', + }) + }, + TEST_TIMEOUT, + ) +}) diff --git a/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts new file mode 100644 index 000000000..3dbec6f3e --- /dev/null +++ b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { createClientManager } from './fixtures' + +describe.sequential('ClientManager integration', () => { + it('caches network details across sequential calls', async () => { + const manager = createClientManager() + + const first = await manager.network() + const second = await manager.network() + const third = await manager.network() + + expect(second).toBe(first) + expect(third).toBe(first) + + expect(first.genesisId.length).toBeGreaterThan(0) + expect(first.genesisHash.length).toBeGreaterThan(0) + const activeFlags = [first.isLocalnet, first.isTestnet, first.isMainnet].filter(Boolean) + expect(activeFlags).toHaveLength(1) + }, 30_000) + + it('deduplicates concurrent network lookups', async () => { + const manager = createClientManager() + + const calls = await Promise.all(Array.from({ length: 6 }, () => manager.network())) + + calls.forEach((result) => { + expect(result).toBe(calls[0]) + }) + }, 30_000) + + it('exposes convenience helpers resolved from the cached network details', async () => { + const manager = createClientManager() + const network = await manager.network() + + const [isLocal, isTest, isMain] = await Promise.all([ + manager.isLocalNet(), + manager.isTestNet(), + manager.isMainNet(), + ]) + + expect(isLocal).toBe(network.isLocalnet) + expect(isTest).toBe(network.isTestnet) + expect(isMain).toBe(network.isMainnet) + }, 30_000) +}) diff --git a/packages/typescript/algokit_utils/tests/clients/fixtures.ts b/packages/typescript/algokit_utils/tests/clients/fixtures.ts new file mode 100644 index 000000000..ebffa8bb9 --- /dev/null +++ b/packages/typescript/algokit_utils/tests/clients/fixtures.ts @@ -0,0 +1,152 @@ +import type { AlgodClient } from '@algorandfoundation/algod-client' +import { AccountManager } from '../../src/clients/account-manager' +import { AssetManager } from '../../src/clients/asset-manager' +import { ClientManager } from '../../src/clients/client-manager' +import { TransactionComposer } from '../../src/transactions/composer' +import type { TransactionSigner } from '../../src/transactions/common' +import { + encodeTransaction, + type AssetCreateParams, + type AssetTransferParams, + type PaymentParams, + type SignedTransaction, + type Transaction, +} from '@algorandfoundation/algokit-transact' +import * as ed from '@noble/ed25519' +import algosdk from 'algosdk' +import { getSenderAccount } from '../algod/helpers' + +export type TestAccount = { + address: string + secretKey: Uint8Array +} + +type AssetTestContext = { + algodClient: AlgodClient + assetManager: AssetManager + creator: TestAccount + signers: AccountManager + newComposer: () => TransactionComposer +} + +export function createClientManager(): ClientManager { + const config = ClientManager.getConfigFromEnvironmentOrLocalNet() + return new ClientManager(config) +} + +export async function createAssetTestContext(): Promise { + const config = ClientManager.getConfigFromEnvironmentOrLocalNet() + const algodClient = ClientManager.getAlgodClient(config.algodConfig) + const creatorAccount = await getSenderAccount() + const creator: TestAccount = { + address: creatorAccount.address, + secretKey: creatorAccount.secretKey, + } + + const signers = new AccountManager() + signers.setSigner(creator.address, createTransactionSigner(creator.secretKey)) + + const newComposer = () => + new TransactionComposer({ + algodClient, + signerGetter: signers, + }) + + const assetManager = new AssetManager(algodClient, newComposer) + + return { + algodClient, + assetManager, + creator, + signers, + newComposer, + } +} + +export async function createTestAsset( + context: AssetTestContext, + overrides: Partial = {}, +): Promise { + const composer = context.newComposer() + const defaultParams: AssetCreateParams = { + sender: context.creator.address, + total: overrides.total ?? 1_000n, + decimals: overrides.decimals ?? 0, + unitName: overrides.unitName ?? 'TEST', + assetName: overrides.assetName ?? 'Test Asset', + } + + composer.addAssetCreate({ ...defaultParams, ...overrides }) + const sendResult = await composer.send({ maxRoundsToWaitForConfirmation: 10 }) + const confirmation = sendResult.results[0]?.confirmation + + if (confirmation?.assetId !== undefined) { + return confirmation.assetId + } + + const txId = sendResult.results[0]?.transactionId + if (!txId) { + throw new Error('Failed to retrieve transaction id for asset creation') + } + + const pending = await context.algodClient.pendingTransactionInformation(txId) + if (pending.assetId === undefined) { + throw new Error('Pending transaction response did not include asset id') + } + return pending.assetId +} + +export async function createFundedAccount( + context: AssetTestContext, + initialFunding: bigint = 5_000_000n, +): Promise { + const generated = algosdk.generateAccount() + const account: TestAccount = { + address: generated.addr, + secretKey: new Uint8Array(generated.sk), + } + + context.signers.setSigner(account.address, createTransactionSigner(account.secretKey)) + await sendPayment(context, { + sender: context.creator.address, + receiver: account.address, + amount: initialFunding, + }) + return account +} + +export async function sendPayment(context: AssetTestContext, params: PaymentParams) { + const composer = context.newComposer() + composer.addPayment(params) + await composer.send({ maxRoundsToWaitForConfirmation: 10 }) +} + +export async function transferAsset(context: AssetTestContext, params: AssetTransferParams) { + const composer = context.newComposer() + composer.addAssetTransfer(params) + await composer.send({ maxRoundsToWaitForConfirmation: 10 }) +} + +function createTransactionSigner(secretKey: Uint8Array): TransactionSigner { + const privateKey = secretKey.slice(0, 32) + + const signSingle = async (transaction: Transaction): Promise => { + const bytes = encodeTransaction(transaction) + const signature = await ed.signAsync(bytes, privateKey) + return { + transaction, + signature, + } + } + + return { + signTransaction: signSingle, + signTransactions: async (transactions: Transaction[], indices: number[]) => { + const signed: SignedTransaction[] = [] + for (const index of indices) { + signed.push(await signSingle(transactions[index])) + } + return signed + }, + } +} diff --git a/packages/typescript/algokit_utils/tests/clients/network-client.test.ts b/packages/typescript/algokit_utils/tests/clients/network-client.test.ts new file mode 100644 index 000000000..d30cd8be7 --- /dev/null +++ b/packages/typescript/algokit_utils/tests/clients/network-client.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { genesisIdIsLocalNet, genesisIdIsMainnet, genesisIdIsTestnet } from '../../src/clients/network-client' + +describe('network helpers', () => { + it('detects localnet genesis identifiers', () => { + expect(genesisIdIsLocalNet('devnet-v1')).toBe(true) + expect(genesisIdIsLocalNet('sandnet-v1')).toBe(true) + expect(genesisIdIsLocalNet('mainnet-v1')).toBe(false) + }) + + it('detects testnet genesis identifiers', () => { + expect(genesisIdIsTestnet('testnet-v1.0')).toBe(true) + expect(genesisIdIsTestnet('testnet')).toBe(true) + expect(genesisIdIsTestnet('dockernet-v1')).toBe(false) + }) + + it('detects mainnet genesis identifiers', () => { + expect(genesisIdIsMainnet('mainnet-v1')).toBe(true) + expect(genesisIdIsMainnet('mainnet')).toBe(true) + expect(genesisIdIsMainnet('testnet-v1.0')).toBe(false) + }) +}) From 265b8c1a1d86ae0b7a2052db5268fa1072a0d6bf Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 16 Oct 2025 14:36:58 +0200 Subject: [PATCH 12/18] chore: consolidating test helpers --- .../typescript/algokit_common/package.json | 9 +- .../tests}/helpers.ts | 193 +++++++++++++----- .../src/clients/asset-manager.ts | 2 +- .../src/clients/http/retry-http-request.ts | 4 +- packages/typescript/algokit_utils/src/temp.ts | 81 -------- .../src/transactions/composer.ts | 26 +-- .../algokit_utils/src/transactions/sender.ts | 26 +-- .../algokit_utils/tests/algod/helpers.ts | 86 -------- .../tests/algod/pendingTransaction.test.ts | 2 +- .../tests/algod/simulateTransactions.test.ts | 2 +- .../tests/algod/transactionParams.test.ts | 2 +- .../algokit_utils/tests/clients/fixtures.ts | 121 +++++++---- .../tests/indexer/searchApplications.test.ts | 2 +- .../tests/indexer/searchTransactions.test.ts | 2 +- packages/typescript/package-lock.json | 26 ++- 15 files changed, 292 insertions(+), 292 deletions(-) rename packages/typescript/{algokit_utils/tests/indexer => algokit_common/tests}/helpers.ts (59%) delete mode 100644 packages/typescript/algokit_utils/src/temp.ts delete mode 100644 packages/typescript/algokit_utils/tests/algod/helpers.ts diff --git a/packages/typescript/algokit_common/package.json b/packages/typescript/algokit_common/package.json index d59438975..af2b125ab 100644 --- a/packages/typescript/algokit_common/package.json +++ b/packages/typescript/algokit_common/package.json @@ -42,5 +42,12 @@ }, "dependencies": {}, "peerDependencies": {}, - "devDependencies": {} + "devDependencies": { + "@algorandfoundation/algod-client": "../algod_client/dist", + "@algorandfoundation/algokit-transact": "../algokit_transact/dist", + "@algorandfoundation/indexer-client": "../indexer_client/dist", + "@algorandfoundation/kmd-client": "../kmd_client/dist", + "@noble/ed25519": "^3.0.0", + "algosdk": "^3.5.0" + } } diff --git a/packages/typescript/algokit_utils/tests/indexer/helpers.ts b/packages/typescript/algokit_common/tests/helpers.ts similarity index 59% rename from packages/typescript/algokit_utils/tests/indexer/helpers.ts rename to packages/typescript/algokit_common/tests/helpers.ts index b2084f1a8..aa7063715 100644 --- a/packages/typescript/algokit_utils/tests/indexer/helpers.ts +++ b/packages/typescript/algokit_common/tests/helpers.ts @@ -1,8 +1,3 @@ -import { describe } from 'vitest' -import algosdk from 'algosdk' -import * as ed from '@noble/ed25519' -import { Buffer } from 'node:buffer' - import { type Transaction, type SignedTransaction, @@ -11,70 +6,169 @@ import { encodeTransaction, encodeSignedTransaction, getTransactionId, + groupTransactions as groupTxns, } from '@algorandfoundation/algokit-transact' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { runWhenIndexerCaughtUp } from '../../src/testing/indexer' - -export interface IndexerTestConfig { - indexerBaseUrl: string - indexerApiToken?: string -} +import { KmdClient } from '@algorandfoundation/kmd-client' +import algosdk from 'algosdk' +import * as ed from '@noble/ed25519' +import { Buffer } from 'node:buffer' +import { runWhenIndexerCaughtUp } from '../../algokit_utils/src/testing/indexer' -export interface CreatedAssetInfo { - assetId: bigint - txId: string +export interface AlgodTestConfig { + algodBaseUrl: string + algodApiToken?: string + senderMnemonic?: string } -export interface CreatedAppInfo { - appId: bigint - txId: string +export function getAlgodEnv(): AlgodTestConfig { + return { + algodBaseUrl: process.env.ALGOD_BASE_URL ?? 'http://localhost:4001', + // Default token for localnet (Algorand sandbox / Algokit LocalNet) + algodApiToken: process.env.ALGOD_API_TOKEN ?? 'a'.repeat(64), + senderMnemonic: process.env.SENDER_MNEMONIC, + } } export async function getSenderMnemonic(): Promise { if (process.env.SENDER_MNEMONIC) return process.env.SENDER_MNEMONIC - const kmdBase = process.env.KMD_BASE_URL ?? 'http://localhost:4002' const kmdToken = process.env.KMD_API_TOKEN ?? 'a'.repeat(64) - const url = new URL(kmdBase) - const server = `${url.protocol}//${url.hostname}` - const port = Number(url.port || 4002) + const walletPassword = process.env.KMD_WALLET_PASSWORD ?? '' + const preferredWalletName = process.env.KMD_WALLET_NAME ?? 'unencrypted-default-wallet' + + const kmd = new KmdClient({ + baseUrl: kmdBase, + apiToken: kmdToken, + }) + + const walletsResponse = await kmd.listWallets() + const wallets = walletsResponse.wallets ?? [] + if (wallets.length === 0) { + throw new Error('No KMD wallets available') + } + + const wallet = + wallets.find((w) => (w.name ?? '').toLowerCase() === preferredWalletName.toLowerCase()) ?? + wallets[0] - // TODO: Replace with native KMD - const kmd = new algosdk.Kmd(kmdToken, server, port) - const wallets = await kmd.listWallets() - const wallet = wallets.wallets.find((w: { name: string }) => w.name === 'unencrypted-default-wallet') ?? wallets.wallets[0] - if (!wallet) throw new Error('No KMD wallet found on localnet') + const walletId = wallet.id + if (!walletId) { + throw new Error('Wallet returned from KMD does not have an id') + } + + const handleResponse = await kmd.initWalletHandleToken({ + body: { + walletId, + walletPassword, + }, + }) + + const walletHandleToken = handleResponse.walletHandleToken + if (!walletHandleToken) { + throw new Error('Failed to obtain wallet handle token from KMD') + } - const handle = await kmd.initWalletHandle(wallet.id, '') try { - const keys = await kmd.listKeys(handle.wallet_handle_token) - let address: string | undefined = keys.addresses[0] + const keysResponse = await kmd.listKeysInWallet({ + body: { + walletHandleToken, + }, + }) + let address = keysResponse.addresses?.[0] if (!address) { - const generated = await kmd.generateKey(handle.wallet_handle_token) - address = generated.address + const generated = await kmd.generateKey({ + body: { + walletHandleToken, + displayMnemonic: false, + }, + }) + address = generated.address ?? undefined + } + + if (!address) { + throw new Error('Unable to determine or generate a wallet key from KMD') + } + + const exportResponse = await kmd.exportKey({ + body: { + walletHandleToken, + walletPassword, + address, + }, + }) + + const exportedKey = exportResponse.privateKey + if (!exportedKey) { + throw new Error('KMD key export did not return a private key') } - const exported = await kmd.exportKey(handle.wallet_handle_token, '', address!) - const sk = new Uint8Array(exported.private_key) - return algosdk.secretKeyToMnemonic(sk) + + const secretKey = new Uint8Array(exportedKey) + return algosdk.secretKeyToMnemonic(secretKey) } finally { - await kmd.releaseWalletHandle(handle.wallet_handle_token) + await kmd + .releaseWalletHandleToken({ + body: { + walletHandleToken, + }, + }) + .catch(() => undefined) } } -async function getSenderAccount(): Promise<{ address: string; secretKey: Uint8Array; mnemonic: string }> { +/** + * Convenience helper: derive the sender account (address + keys) used for tests. + * Returns: + * - address: Algorand address string + * - secretKey: 64-byte Ed25519 secret key (private + public) + * - mnemonic: the 25-word mnemonic + */ +export async function getSenderAccount(): Promise<{ + address: string + secretKey: Uint8Array + mnemonic: string +}> { const mnemonic = await getSenderMnemonic() - const { addr, sk } = algosdk.mnemonicToSecretKey(mnemonic) - const address = typeof addr === 'string' ? addr : addr.toString() - return { address, secretKey: new Uint8Array(sk), mnemonic } + const { addr, sk } = algosdk.mnemonicToSecretKey(mnemonic) // TODO: Remove algosdk dependency + const secretKey = new Uint8Array(sk) + return { address: typeof addr === 'string' ? addr : addr.toString(), secretKey, mnemonic } +} + +export async function signTransaction(transaction: Transaction, secretKey: Uint8Array): Promise { + const encodedTxn = encodeTransaction(transaction) + const signature = await ed.signAsync(encodedTxn, secretKey.slice(0, 32)) + + return { + transaction, + signature, + } +} + +export function groupTransactions(transactions: Transaction[]): Transaction[] { + return groupTxns(transactions) +} + +export interface IndexerTestConfig { + indexerBaseUrl: string + indexerApiToken?: string +} + +export interface CreatedAssetInfo { + assetId: bigint + txId: string +} + +export interface CreatedAppInfo { + appId: bigint + txId: string } function getAlgodClient(): algosdk.Algodv2 { - const algodBase = process.env.ALGOD_BASE_URL ?? 'http://localhost:4001' - const algodToken = process.env.ALGOD_API_TOKEN ?? 'a'.repeat(64) - const url = new URL(algodBase) + const env = getAlgodEnv() + const url = new URL(env.algodBaseUrl) const server = `${url.protocol}//${url.hostname}` const port = Number(url.port || 4001) - return new algosdk.Algodv2(algodToken, server, port) + return new algosdk.Algodv2(env.algodApiToken, server, port) } function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { @@ -84,15 +178,6 @@ function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { return new Uint8Array(Buffer.from(genesisHash, 'base64')) } -async function signTransaction(transaction: Transaction, secretKey: Uint8Array): Promise { - const encodedTxn = encodeTransaction(transaction) - const signature = await ed.signAsync(encodedTxn, secretKey.slice(0, 32)) - return { - transaction, - signature, - } -} - async function submitTransaction(transaction: Transaction, algod: algosdk.Algodv2, secretKey: Uint8Array): Promise<{ txId: string }> { const signed = await signTransaction(transaction, secretKey) const raw = encodeSignedTransaction(signed) @@ -202,10 +287,6 @@ export function getIndexerEnv(): IndexerTestConfig { } } -export function maybeDescribe(name: string, fn: (env: IndexerTestConfig) => void) { - describe(name, () => fn(getIndexerEnv())) -} - export async function waitForIndexerTransaction(indexer: IndexerClient, txId: string): Promise { await runWhenIndexerCaughtUp(async () => { await indexer.lookupTransaction(txId) diff --git a/packages/typescript/algokit_utils/src/clients/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index 5a0542058..2b958b4ff 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -219,7 +219,7 @@ export class AssetManager { */ async getAccountInformation(sender: string, assetId: bigint): Promise { try { - return await this.algodClient.accountAssetInformation(sender, assetId) + return await this.algodClient.accountAssetInformation(sender, assetId, { format: 'json' }) } catch (error) { if (error instanceof ApiError) { if (error.status === 404) { diff --git a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts index a05f9f9b8..baecf72c4 100644 --- a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts +++ b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts @@ -157,7 +157,9 @@ export class RetryHttpRequest extends BaseHttpRequest { headers['Authorization'] = `Basic ${btoa(`${this.config.username}:${this.config.password}`)}` } - let payload: BodyInit | undefined + type FetchRequestInit = Parameters[1] + type FetchBody = FetchRequestInit extends { body?: infer B } ? B : undefined + let payload: FetchBody if (options.body != null) { const { body } = options if (body instanceof Uint8Array) { diff --git a/packages/typescript/algokit_utils/src/temp.ts b/packages/typescript/algokit_utils/src/temp.ts deleted file mode 100644 index 4027babd2..000000000 --- a/packages/typescript/algokit_utils/src/temp.ts +++ /dev/null @@ -1,81 +0,0 @@ -// TODO: These types will be replaced by the OAS generated types when available - -import { Transaction } from '@algorandfoundation/algokit-transact' - -export type TransactionParams = { - /** ConsensusVersion indicates the consensus protocol version as of LastRound. */ - consensusVersion: string - - /** Fee is the suggested transaction fee - * Fee is in units of micro-Algos per byte. - * Fee may fall to zero but transactions must still have a fee of - * at least MinTxnFee for the current network protocol. - */ - fee: bigint - - /** GenesisHash is the hash of the genesis block. */ - genesisHash: Uint8Array - - /** GenesisID is an ID listed in the genesis block. */ - genesisId: string - - /** LastRound indicates the last round seen */ - lastRound: bigint - - /** The minimum transaction fee (not per byte) required for the txn to validate for the current network protocol. */ - minFee: bigint -} - -// Resource population types based on Rust implementation -export type BoxReference = { - app: bigint - name: Uint8Array -} - -export type AssetHoldingReference = { - asset: bigint - account: string -} - -export type ApplicationLocalReference = { - app: bigint - account: string -} - -export type SimulateUnnamedResourcesAccessed = { - accounts?: string[] - apps?: bigint[] - assets?: bigint[] - boxes?: BoxReference[] - extraBoxRefs?: number - appLocals?: ApplicationLocalReference[] - assetHoldings?: AssetHoldingReference[] -} - -export type SimulateTransactionResult = { - txnResult: { - innerTxns?: PendingTransactionResponse[] - } - unnamedResourcesAccessed?: SimulateUnnamedResourcesAccessed -} - -export type PendingTransactionResponse = { - txn: { - transaction: Transaction - } - innerTxns?: PendingTransactionResponse[] - logs?: Uint8Array[] - poolError?: string - confirmedRound?: bigint - assetIndex?: bigint - applicationIndex?: bigint -} - -export type SimulateResponse = { - txnGroups: Array<{ - txnResults: SimulateTransactionResult[] - unnamedResourcesAccessed?: SimulateUnnamedResourcesAccessed - failureMessage?: string - failedAt?: number[] - }> -} diff --git a/packages/typescript/algokit_utils/src/transactions/composer.ts b/packages/typescript/algokit_utils/src/transactions/composer.ts index e1a735cfd..0b680ccba 100644 --- a/packages/typescript/algokit_utils/src/transactions/composer.ts +++ b/packages/typescript/algokit_utils/src/transactions/composer.ts @@ -23,17 +23,19 @@ import { getTransactionId, groupTransactions, } from '@algorandfoundation/algokit-transact' -import { AlgodClient, ApiError } from '@algorandfoundation/algod-client' -import { genesisIdIsLocalNet } from '../clients/network-client' import { - ApplicationLocalReference, - AssetHoldingReference, - BoxReference, - PendingTransactionResponse, - SimulateResponse, - SimulateUnnamedResourcesAccessed, - TransactionParams, -} from '../temp' + AlgodClient, + ApiError, + type ApplicationLocalReference, + type AssetHoldingReference, + type BoxReference, + type PendingTransactionResponse, + type SimulateRequest, + type SimulateTransaction, + type SimulateUnnamedResourcesAccessed, + type TransactionParams, +} from '@algorandfoundation/algod-client' +import { genesisIdIsLocalNet } from '../clients/network-client' import { AppCallMethodCallParams, AppCallParams, @@ -666,7 +668,7 @@ export class TransactionComposer { }) satisfies SignedTransaction, ) - const simulateRequest = { + const simulateRequest: SimulateRequest = { txnGroups: [ { txns: signedTransactions, @@ -677,7 +679,7 @@ export class TransactionComposer { fixSigners: true, } - const response: SimulateResponse = await this.algodClient.simulateTransaction(simulateRequest) + const response: SimulateTransaction = await this.algodClient.simulateTransaction({ body: simulateRequest }) const groupResponse = response.txnGroups[0] // Handle any simulation failures diff --git a/packages/typescript/algokit_utils/src/transactions/sender.ts b/packages/typescript/algokit_utils/src/transactions/sender.ts index 466de60bf..3d698ba5e 100644 --- a/packages/typescript/algokit_utils/src/transactions/sender.ts +++ b/packages/typescript/algokit_utils/src/transactions/sender.ts @@ -1,7 +1,7 @@ import { Expand } from '@algorandfoundation/algokit-common' import { Transaction } from '@algorandfoundation/algokit-transact' import { AssetManager } from '../clients/asset-manager' -import { PendingTransactionResponse } from '../temp' +import type { PendingTransactionResponse } from '@algorandfoundation/algod-client' import type { AppCallMethodCallParams, AppCallParams, @@ -137,13 +137,13 @@ export class TransactionSender { params, (composer, p) => composer.addAssetCreate(p), (baseResult) => { - const assetIndex = baseResult.confirmation.assetIndex - if (assetIndex === undefined || assetIndex <= 0) { - throw new Error('Asset creation confirmation missing assetIndex') + const assetId = baseResult.confirmation.assetId + if (assetId === undefined || assetId <= 0) { + throw new Error('Asset creation confirmation missing assetId') } return { ...baseResult, - assetId: assetIndex, + assetId: assetId, } }, sendParams, @@ -308,13 +308,13 @@ export class TransactionSender { params, (composer, p) => composer.addAppCreate(p), (baseResult) => { - const applicationIndex = baseResult.confirmation.applicationIndex - if (applicationIndex === undefined || applicationIndex <= 0) { - throw new Error('App creation confirmation missing applicationIndex') + const appId = baseResult.confirmation.appId + if (appId === undefined || appId <= 0) { + throw new Error('App creation confirmation missing appId') } return { ...baseResult, - appId: applicationIndex, + appId: appId, } }, sendParams, @@ -366,13 +366,13 @@ export class TransactionSender { params, (composer, p) => composer.addAppCreateMethodCall(p), (baseResult) => { - const applicationIndex = baseResult.result.confirmation.applicationIndex - if (applicationIndex === undefined || applicationIndex <= 0) { - throw new Error('App creation confirmation missing applicationIndex') + const appId = baseResult.result.confirmation.appId + if (appId === undefined || appId <= 0) { + throw new Error('App creation confirmation missing appId') } return { ...baseResult, - appId: applicationIndex, + appId: appId, } }, sendParams, diff --git a/packages/typescript/algokit_utils/tests/algod/helpers.ts b/packages/typescript/algokit_utils/tests/algod/helpers.ts deleted file mode 100644 index ed061acc7..000000000 --- a/packages/typescript/algokit_utils/tests/algod/helpers.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - type Transaction, - type SignedTransaction, - encodeTransaction, - groupTransactions as groupTxns, -} from '@algorandfoundation/algokit-transact' -import algosdk from 'algosdk' -import * as ed from '@noble/ed25519' - -export interface AlgodTestConfig { - algodBaseUrl: string - algodApiToken?: string - senderMnemonic?: string -} - -export function getAlgodEnv(): AlgodTestConfig { - return { - algodBaseUrl: process.env.ALGOD_BASE_URL ?? 'http://localhost:4001', - // Default token for localnet (Algorand sandbox / Algokit LocalNet) - algodApiToken: process.env.ALGOD_API_TOKEN ?? 'a'.repeat(64), - senderMnemonic: process.env.SENDER_MNEMONIC, - } -} - -export async function getSenderMnemonic(): Promise { - if (process.env.SENDER_MNEMONIC) return process.env.SENDER_MNEMONIC - const algosdk = (await import('algosdk')).default - // Try to derive from local KMD defaults - const kmdBase = process.env.KMD_BASE_URL ?? 'http://localhost:4002' - const kmdToken = process.env.KMD_API_TOKEN ?? 'a'.repeat(64) - const url = new URL(kmdBase) - const server = `${url.protocol}//${url.hostname}` - const port = Number(url.port || 4002) - - // TODO: Replace with native KMD - const kmd = new algosdk.Kmd(kmdToken, server, port) - const wallets = await kmd.listWallets() - const wallet = wallets.wallets.find((w: { name: string }) => w.name === 'unencrypted-default-wallet') ?? wallets.wallets[0] - if (!wallet) throw new Error('No KMD wallet found on localnet') - const handle = await kmd.initWalletHandle(wallet.id, '') - try { - const keys = await kmd.listKeys(handle.wallet_handle_token) - let address: string | undefined = keys.addresses[0] - if (!address) { - const gen = await kmd.generateKey(handle.wallet_handle_token) - address = gen.address - } - const exported = await kmd.exportKey(handle.wallet_handle_token, '', address!) - const sk = new Uint8Array(exported.private_key) - return algosdk.secretKeyToMnemonic(sk) - } finally { - await kmd.releaseWalletHandle(handle.wallet_handle_token) - } -} - -/** - * Convenience helper: derive the sender account (address + keys) used for tests. - * Returns: - * - address: Algorand address string - * - secretKey: 64-byte Ed25519 secret key (private + public) - * - mnemonic: the 25-word mnemonic - */ -export async function getSenderAccount(): Promise<{ - address: string - secretKey: Uint8Array - mnemonic: string -}> { - const mnemonic = await getSenderMnemonic() - const { addr, sk } = algosdk.mnemonicToSecretKey(mnemonic) - const secretKey = new Uint8Array(sk) - return { address: typeof addr === 'string' ? addr : addr.toString(), secretKey, mnemonic } -} - -export async function signTransaction(transaction: Transaction, secretKey: Uint8Array): Promise { - const encodedTxn = encodeTransaction(transaction) - const signature = await ed.signAsync(encodedTxn, secretKey.slice(0, 32)) - - return { - transaction, - signature, - } -} - -export function groupTransactions(transactions: Transaction[]): Transaction[] { - return groupTxns(transactions) -} diff --git a/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts b/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts index 39e69e604..852d0143e 100644 --- a/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts +++ b/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts @@ -1,7 +1,7 @@ import { expect, it, describe } from 'vitest' import { AlgodClient, PendingTransactionResponse } from '@algorandfoundation/algod-client' import { encodeSignedTransaction, getTransactionId, TransactionType, type Transaction } from '@algorandfoundation/algokit-transact' -import { getAlgodEnv, getSenderAccount, signTransaction } from './helpers' +import { getAlgodEnv, getSenderAccount, signTransaction } from '../../../algokit_common/tests/helpers' describe('Algod pendingTransaction', () => { it('submits a payment tx and queries pending info', async () => { diff --git a/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts b/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts index b148c8415..9ac61086b 100644 --- a/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts +++ b/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts @@ -1,7 +1,7 @@ import { expect, it, describe } from 'vitest' import { AlgodClient, ClientConfig, SimulateRequest } from '@algorandfoundation/algod-client' import { TransactionType, type SignedTransaction, type Transaction } from '@algorandfoundation/algokit-transact' -import { getAlgodEnv, getSenderAccount, groupTransactions, signTransaction } from './helpers' +import { getAlgodEnv, getSenderAccount, groupTransactions, signTransaction } from '../../../algokit_common/tests/helpers' describe('simulateTransactions', () => { it('should simulate two transactions and decode msgpack response', async () => { diff --git a/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts b/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts index 865a71d73..584b8fe54 100644 --- a/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts +++ b/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts @@ -1,6 +1,6 @@ import { expect, it, describe } from 'vitest' import { AlgodClient } from '@algorandfoundation/algod-client' -import { getAlgodEnv } from './helpers' +import { getAlgodEnv } from '../../../algokit_common/tests/helpers' describe('transactionParams', () => { it('should fetch transaction params', async () => { diff --git a/packages/typescript/algokit_utils/tests/clients/fixtures.ts b/packages/typescript/algokit_utils/tests/clients/fixtures.ts index ebffa8bb9..8b82de738 100644 --- a/packages/typescript/algokit_utils/tests/clients/fixtures.ts +++ b/packages/typescript/algokit_utils/tests/clients/fixtures.ts @@ -1,20 +1,16 @@ import type { AlgodClient } from '@algorandfoundation/algod-client' -import { AccountManager } from '../../src/clients/account-manager' +import { signTransaction as signTransactionHelper, getSenderAccount } from '../../../algokit_common/tests/helpers' import { AssetManager } from '../../src/clients/asset-manager' import { ClientManager } from '../../src/clients/client-manager' import { TransactionComposer } from '../../src/transactions/composer' -import type { TransactionSigner } from '../../src/transactions/common' -import { - encodeTransaction, - type AssetCreateParams, - type AssetTransferParams, - type PaymentParams, - type SignedTransaction, - type Transaction, -} from '@algorandfoundation/algokit-transact' -import * as ed from '@noble/ed25519' +import type { SignerGetter, TransactionSigner } from '../../src/transactions/common' +import type { AssetCreateParams } from '../../src/transactions/asset-config' +import type { AssetTransferParams } from '../../src/transactions/asset-transfer' +import type { PaymentParams } from '../../src/transactions/payment' +import type { Transaction, SignedTransaction } from '@algorandfoundation/algokit-transact' +import type { PendingTransactionResponse } from '@algorandfoundation/algod-client' import algosdk from 'algosdk' -import { getSenderAccount } from '../algod/helpers' +import { randomBytes } from 'crypto' export type TestAccount = { address: string @@ -25,10 +21,44 @@ type AssetTestContext = { algodClient: AlgodClient assetManager: AssetManager creator: TestAccount - signers: AccountManager + signers: InMemorySignerRegistry newComposer: () => TransactionComposer } +class InMemorySignerRegistry implements SignerGetter { + private readonly signers = new Map() + private readonly secrets = new Map() + private defaultSigner?: TransactionSigner + private defaultSecret?: Uint8Array + + register(address: string, secretKey: Uint8Array): void { + this.secrets.set(address, secretKey) + this.signers.set(address, createTransactionSigner(secretKey)) + } + + setDefault(address: string, secretKey: Uint8Array): void { + this.defaultSecret = secretKey + this.defaultSigner = createTransactionSigner(secretKey) + this.register(address, secretKey) + } + + getSigner(address: string): TransactionSigner { + const signer = this.signers.get(address) ?? this.defaultSigner + if (!signer) { + throw new Error(`No signer registered for address ${address}`) + } + return signer + } + + getSecret(address: string): Uint8Array { + const secret = this.secrets.get(address) ?? this.defaultSecret + if (!secret) { + throw new Error(`No secret key registered for address ${address}`) + } + return secret + } +} + export function createClientManager(): ClientManager { const config = ClientManager.getConfigFromEnvironmentOrLocalNet() return new ClientManager(config) @@ -43,8 +73,8 @@ export async function createAssetTestContext(): Promise { secretKey: creatorAccount.secretKey, } - const signers = new AccountManager() - signers.setSigner(creator.address, createTransactionSigner(creator.secretKey)) + const signers = new InMemorySignerRegistry() + signers.setDefault(creator.address, creator.secretKey) const newComposer = () => new TransactionComposer({ @@ -68,30 +98,36 @@ export async function createTestAsset( overrides: Partial = {}, ): Promise { const composer = context.newComposer() - const defaultParams: AssetCreateParams = { + const params: AssetCreateParams = { sender: context.creator.address, total: overrides.total ?? 1_000n, decimals: overrides.decimals ?? 0, unitName: overrides.unitName ?? 'TEST', assetName: overrides.assetName ?? 'Test Asset', + defaultFrozen: overrides.defaultFrozen, + manager: overrides.manager, + reserve: overrides.reserve, + freeze: overrides.freeze, + clawback: overrides.clawback, + note: new Uint8Array(randomBytes(8)), } - composer.addAssetCreate({ ...defaultParams, ...overrides }) + composer.addAssetCreate({ ...params, ...overrides }) const sendResult = await composer.send({ maxRoundsToWaitForConfirmation: 10 }) - const confirmation = sendResult.results[0]?.confirmation - if (confirmation?.assetId !== undefined) { + const confirmation = sendResult.results.at(-1)?.confirmation + if (confirmation?.assetId !== undefined && confirmation.assetId > 0) { + await waitForConfirmation(context.algodClient, sendResult.results.at(-1)!.transactionId) return confirmation.assetId } - const txId = sendResult.results[0]?.transactionId + const txId = sendResult.results.at(-1)?.transactionId if (!txId) { - throw new Error('Failed to retrieve transaction id for asset creation') + throw new Error('Asset creation composer did not return a transaction id') } - - const pending = await context.algodClient.pendingTransactionInformation(txId) + const pending = await waitForConfirmation(context.algodClient, txId) if (pending.assetId === undefined) { - throw new Error('Pending transaction response did not include asset id') + throw new Error('Pending transaction response missing assetId') } return pending.assetId } @@ -100,13 +136,13 @@ export async function createFundedAccount( context: AssetTestContext, initialFunding: bigint = 5_000_000n, ): Promise { - const generated = algosdk.generateAccount() + const generated = algosdk.generateAccount() // TODO: Remove algosdk dependency const account: TestAccount = { - address: generated.addr, + address: generated.addr.toString(), secretKey: new Uint8Array(generated.sk), } - context.signers.setSigner(account.address, createTransactionSigner(account.secretKey)) + context.signers.register(account.address, account.secretKey) await sendPayment(context, { sender: context.creator.address, receiver: account.address, @@ -128,15 +164,11 @@ export async function transferAsset(context: AssetTestContext, params: AssetTran } function createTransactionSigner(secretKey: Uint8Array): TransactionSigner { - const privateKey = secretKey.slice(0, 32) - const signSingle = async (transaction: Transaction): Promise => { - const bytes = encodeTransaction(transaction) - const signature = await ed.signAsync(bytes, privateKey) - return { - transaction, - signature, + if (!transaction.sender) { + throw new Error('Transaction missing sender') } + return signTransactionHelper(transaction, secretKey) } return { @@ -144,9 +176,28 @@ function createTransactionSigner(secretKey: Uint8Array): TransactionSigner { signTransactions: async (transactions: Transaction[], indices: number[]) => { const signed: SignedTransaction[] = [] for (const index of indices) { - signed.push(await signSingle(transactions[index])) + const transaction = transactions[index] + if (!transaction) { + throw new Error(`Missing transaction at index ${index}`) + } + signed.push(await signSingle(transaction)) } return signed }, } } + +async function waitForConfirmation(algod: AlgodClient, txId: string, attempts = 30): Promise { + for (let i = 0; i < attempts; i++) { + const pending = await algod.pendingTransactionInformation(txId) + if (pending.confirmedRound !== undefined && pending.confirmedRound > 0n) { + return pending + } + await delay(500) + } + throw new Error(`Transaction ${txId} unconfirmed after ${attempts} attempts`) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts index 4a9ae6651..8dee8a689 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts @@ -1,6 +1,6 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyApp, getIndexerEnv, waitForIndexerTransaction } from './helpers' +import { createDummyApp, getIndexerEnv, waitForIndexerTransaction } from '../../../algokit_common/tests/helpers' describe('Indexer search applications', () => { it('should search for applications', async () => { diff --git a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts index 65eee0a62..f003c9e24 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts @@ -1,6 +1,6 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyAsset, getIndexerEnv, waitForIndexerTransaction } from './helpers' +import { createDummyAsset, getIndexerEnv, waitForIndexerTransaction } from '../../../algokit_common/tests/helpers' describe('Indexer search transactions', () => { it('should search for transactions', async () => { diff --git a/packages/typescript/package-lock.json b/packages/typescript/package-lock.json index e4f666a8e..95fa380e4 100644 --- a/packages/typescript/package-lock.json +++ b/packages/typescript/package-lock.json @@ -113,7 +113,14 @@ "name": "@algorandfoundation/algokit-common", "version": "0.1.0", "license": "MIT", - "devDependencies": {}, + "devDependencies": { + "@algorandfoundation/algod-client": "../algod_client/dist", + "@algorandfoundation/algokit-transact": "../algokit_transact/dist", + "@algorandfoundation/indexer-client": "../indexer_client/dist", + "@algorandfoundation/kmd-client": "../kmd_client/dist", + "@noble/ed25519": "^3.0.0", + "algosdk": "^3.5.0" + }, "engines": { "node": ">=20.0" } @@ -127,6 +134,22 @@ "node": ">=20.0" } }, + "algokit_common/node_modules/@algorandfoundation/algod-client": { + "resolved": "algod_client/dist", + "link": true + }, + "algokit_common/node_modules/@algorandfoundation/algokit-transact": { + "resolved": "algokit_transact/dist", + "link": true + }, + "algokit_common/node_modules/@algorandfoundation/indexer-client": { + "resolved": "indexer_client/dist", + "link": true + }, + "algokit_common/node_modules/@algorandfoundation/kmd-client": { + "resolved": "kmd_client/dist", + "link": true + }, "algokit_transact": { "name": "@algorandfoundation/algokit-transact", "version": "0.1.0", @@ -141,6 +164,7 @@ "algokit_transact/dist": { "name": "@algorandfoundation/algokit-transact", "version": "0.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=20.0" From 6c2cfef28dc2ac6aa9ceab436f46821cfd135984 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 16 Oct 2025 14:51:12 +0200 Subject: [PATCH 13/18] chore: consolidate lint and format scripts; ensure all packages run lint as part of build --- .github/workflows/api_ci.yml | 7 ++---- packages/typescript/algokit_abi/package.json | 2 +- .../typescript/algokit_common/package.json | 2 +- .../algokit_common/tests/helpers.ts | 4 +--- .../typescript/algokit_transact/package.json | 2 +- .../typescript/algokit_utils/package.json | 2 +- .../algokit_utils/src/algorand-client.ts | 2 ++ .../src/clients/http/retry-http-request.ts | 2 +- .../tests/clients/client-manager.test.ts | 23 +++++++++++++++---- .../algokit_utils/tests/clients/fixtures.ts | 10 ++------ packages/typescript/package.json | 4 ++++ 11 files changed, 34 insertions(+), 26 deletions(-) diff --git a/.github/workflows/api_ci.yml b/.github/workflows/api_ci.yml index a35cf882b..8b29bc082 100644 --- a/.github/workflows/api_ci.yml +++ b/.github/workflows/api_ci.yml @@ -129,13 +129,10 @@ jobs: working-directory: packages/typescript run: npm run lint --workspace ${{ matrix.workspace }} - - name: Build all TypeScript packages + - name: Build workspace working-directory: packages/typescript run: | - npm run build --workspace @algorandfoundation/algokit-common - npm run build --workspace @algorandfoundation/algokit-abi - npm run build --workspace @algorandfoundation/algokit-transact - npm run build --workspace ${{ matrix.workspace }} + npm run build - name: Update package links after build working-directory: packages/typescript diff --git a/packages/typescript/algokit_abi/package.json b/packages/typescript/algokit_abi/package.json index a56fab036..14bb1fb85 100644 --- a/packages/typescript/algokit_abi/package.json +++ b/packages/typescript/algokit_abi/package.json @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s build:*", + "build": "run-s lint build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", "build:1-compile": "rolldown -c", diff --git a/packages/typescript/algokit_common/package.json b/packages/typescript/algokit_common/package.json index af2b125ab..cd5cb5311 100644 --- a/packages/typescript/algokit_common/package.json +++ b/packages/typescript/algokit_common/package.json @@ -25,7 +25,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s build:*", + "build": "run-s lint build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", "build:1-compile": "rolldown -c", diff --git a/packages/typescript/algokit_common/tests/helpers.ts b/packages/typescript/algokit_common/tests/helpers.ts index aa7063715..323a4f215 100644 --- a/packages/typescript/algokit_common/tests/helpers.ts +++ b/packages/typescript/algokit_common/tests/helpers.ts @@ -48,9 +48,7 @@ export async function getSenderMnemonic(): Promise { throw new Error('No KMD wallets available') } - const wallet = - wallets.find((w) => (w.name ?? '').toLowerCase() === preferredWalletName.toLowerCase()) ?? - wallets[0] + const wallet = wallets.find((w) => (w.name ?? '').toLowerCase() === preferredWalletName.toLowerCase()) ?? wallets[0] const walletId = wallet.id if (!walletId) { diff --git a/packages/typescript/algokit_transact/package.json b/packages/typescript/algokit_transact/package.json index 4f0f66cae..72254df7d 100644 --- a/packages/typescript/algokit_transact/package.json +++ b/packages/typescript/algokit_transact/package.json @@ -25,7 +25,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s build:*", + "build": "run-s lint build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", "build:1-compile": "rolldown -c", diff --git a/packages/typescript/algokit_utils/package.json b/packages/typescript/algokit_utils/package.json index 35d86709d..6051499ef 100644 --- a/packages/typescript/algokit_utils/package.json +++ b/packages/typescript/algokit_utils/package.json @@ -25,7 +25,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s build:*", + "build": "run-s lint build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", "build:1-compile": "rolldown -c", diff --git a/packages/typescript/algokit_utils/src/algorand-client.ts b/packages/typescript/algokit_utils/src/algorand-client.ts index c205d9b67..2fb6b7eed 100644 --- a/packages/typescript/algokit_utils/src/algorand-client.ts +++ b/packages/typescript/algokit_utils/src/algorand-client.ts @@ -1,3 +1,5 @@ +// disable eslint for this file TODO: Remove once algorand-client is fully implemented +/* eslint-disable */ import { AlgoConfig } from './clients/network-client' import { TransactionComposerConfig } from './transactions/composer' import type { AlgodClient } from '@algorandfoundation/algod-client' diff --git a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts index baecf72c4..7c50663d2 100644 --- a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts +++ b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts @@ -142,7 +142,7 @@ export class RetryHttpRequest extends BaseHttpRequest { } const headers: Record = { - ...(typeof this.config.headers === 'function' ? await this.config.headers() : this.config.headers ?? {}), + ...(typeof this.config.headers === 'function' ? await this.config.headers() : (this.config.headers ?? {})), ...(options.headers ?? {}), } diff --git a/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts index 3dbec6f3e..660c91488 100644 --- a/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts +++ b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts @@ -32,14 +32,27 @@ describe.sequential('ClientManager integration', () => { const manager = createClientManager() const network = await manager.network() - const [isLocal, isTest, isMain] = await Promise.all([ - manager.isLocalNet(), - manager.isTestNet(), - manager.isMainNet(), - ]) + const [isLocal, isTest, isMain] = await Promise.all([manager.isLocalNet(), manager.isTestNet(), manager.isMainNet()]) expect(isLocal).toBe(network.isLocalnet) expect(isTest).toBe(network.isTestnet) expect(isMain).toBe(network.isMainnet) }, 30_000) + + it('validates network details structure for localnet', async () => { + const manager = createClientManager() + const details = await manager.network() + + // Verify structure + expect(details.genesisId.length).toBeGreaterThan(0) + expect(details.genesisHash.length).toBeGreaterThan(0) + + // Verify exactly one network type is detected + const networkFlags = [details.isLocalnet, details.isTestnet, details.isMainnet] + const activeNetworks = networkFlags.filter(Boolean) + expect(activeNetworks).toHaveLength(1) + + // Should detect as localnet for local config + expect(details.isLocalnet).toBe(true) + }, 30_000) }) diff --git a/packages/typescript/algokit_utils/tests/clients/fixtures.ts b/packages/typescript/algokit_utils/tests/clients/fixtures.ts index 8b82de738..6555a9149 100644 --- a/packages/typescript/algokit_utils/tests/clients/fixtures.ts +++ b/packages/typescript/algokit_utils/tests/clients/fixtures.ts @@ -93,10 +93,7 @@ export async function createAssetTestContext(): Promise { } } -export async function createTestAsset( - context: AssetTestContext, - overrides: Partial = {}, -): Promise { +export async function createTestAsset(context: AssetTestContext, overrides: Partial = {}): Promise { const composer = context.newComposer() const params: AssetCreateParams = { sender: context.creator.address, @@ -132,10 +129,7 @@ export async function createTestAsset( return pending.assetId } -export async function createFundedAccount( - context: AssetTestContext, - initialFunding: bigint = 5_000_000n, -): Promise { +export async function createFundedAccount(context: AssetTestContext, initialFunding: bigint = 5_000_000n): Promise { const generated = algosdk.generateAccount() // TODO: Remove algosdk dependency const account: TestAccount = { address: generated.addr.toString(), diff --git a/packages/typescript/package.json b/packages/typescript/package.json index bf8a26c9b..40eed2545 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -16,6 +16,10 @@ "build": "npm run build --workspaces --if-present", "build-watch": "npm run build-watch --workspace=algokit_common & npm run build-watch --workspace=algokit_abi & npm run build-watch --workspace=algokit_transact & npm run build-watch --workspace=algod_client & npm run build-watch --workspace=algokit_utils", "test": "npm run test --workspaces --if-present", + "lint": "npm run lint --workspaces --if-present", + "lint:fix": "npm run lint:fix --workspaces --if-present", + "format": "run-s lint:fix format:prettier", + "format:prettier": "npm run format --workspaces --if-present", "pre-commit": "npm run pre-commit --workspaces --if-present" }, "engines": { From 4c70c52e65daa1ca8dbbc24b76ec5521e8fe0013 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 16 Oct 2025 16:24:28 +0200 Subject: [PATCH 14/18] chore: further removal of dependencies on algosdk --- .../algokit_common/tests/helpers.ts | 53 ++++++++++--------- .../typescript/algokit_utils/src/index.ts | 1 + .../algokit_utils/src/transactions/sender.ts | 6 +-- .../algokit_utils/tests/clients/fixtures.ts | 18 +------ .../tests/indexer/searchApplications.test.ts | 7 ++- .../tests/indexer/searchTransactions.test.ts | 7 ++- 6 files changed, 42 insertions(+), 50 deletions(-) diff --git a/packages/typescript/algokit_common/tests/helpers.ts b/packages/typescript/algokit_common/tests/helpers.ts index 323a4f215..574c375d2 100644 --- a/packages/typescript/algokit_common/tests/helpers.ts +++ b/packages/typescript/algokit_common/tests/helpers.ts @@ -8,12 +8,11 @@ import { getTransactionId, groupTransactions as groupTxns, } from '@algorandfoundation/algokit-transact' -import { IndexerClient } from '@algorandfoundation/indexer-client' import { KmdClient } from '@algorandfoundation/kmd-client' +import { AlgodClient, PendingTransactionResponse } from '@algorandfoundation/algod-client' import algosdk from 'algosdk' import * as ed from '@noble/ed25519' import { Buffer } from 'node:buffer' -import { runWhenIndexerCaughtUp } from '../../algokit_utils/src/testing/indexer' export interface AlgodTestConfig { algodBaseUrl: string @@ -30,6 +29,17 @@ export function getAlgodEnv(): AlgodTestConfig { } } +export async function waitForConfirmation(algod: AlgodClient, txId: string, attempts = 30): Promise { + for (let i = 0; i < attempts; i++) { + const pending = await algod.pendingTransactionInformation(txId) + if (pending.confirmedRound !== undefined && pending.confirmedRound > 0n) { + return pending + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`Transaction ${txId} unconfirmed after ${attempts} attempts`) +} + export async function getSenderMnemonic(): Promise { if (process.env.SENDER_MNEMONIC) return process.env.SENDER_MNEMONIC const kmdBase = process.env.KMD_BASE_URL ?? 'http://localhost:4002' @@ -161,12 +171,9 @@ export interface CreatedAppInfo { txId: string } -function getAlgodClient(): algosdk.Algodv2 { +function getAlgodClient(): AlgodClient { const env = getAlgodEnv() - const url = new URL(env.algodBaseUrl) - const server = `${url.protocol}//${url.hostname}` - const port = Number(url.port || 4001) - return new algosdk.Algodv2(env.algodApiToken, server, port) + return new AlgodClient({ baseUrl: env.algodBaseUrl, apiToken: env.algodApiToken }) } function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { @@ -176,21 +183,21 @@ function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { return new Uint8Array(Buffer.from(genesisHash, 'base64')) } -async function submitTransaction(transaction: Transaction, algod: algosdk.Algodv2, secretKey: Uint8Array): Promise<{ txId: string }> { +async function submitTransaction(transaction: Transaction, algod: AlgodClient, secretKey: Uint8Array): Promise<{ txId: string }> { const signed = await signTransaction(transaction, secretKey) const raw = encodeSignedTransaction(signed) const txId = getTransactionId(transaction) - await algod.sendRawTransaction(raw).do() - await algosdk.waitForConfirmation(algod, txId, 10) + await algod.rawTransaction({ body: raw }) + await waitForConfirmation(algod, txId, 10) return { txId } } export async function createDummyAsset(): Promise { const { address, secretKey } = await getSenderAccount() const algod = getAlgodClient() - const sp = await algod.getTransactionParams().do() + const sp = await algod.transactionParams() - const firstValid = BigInt(sp.firstValid ?? sp.lastValid) + const firstValid = sp.lastRound const lastValid = firstValid + 1_000n const transaction: Transaction = { @@ -199,7 +206,7 @@ export async function createDummyAsset(): Promise { firstValid, lastValid, genesisHash: decodeGenesisHash(sp.genesisHash), - genesisId: sp.genesisID, + genesisId: sp.genesisId, fee: sp.minFee, assetConfig: { assetId: 0n, @@ -217,7 +224,7 @@ export async function createDummyAsset(): Promise { const { txId } = await submitTransaction(transaction, algod, secretKey) - const assetId = (await algod.pendingTransactionInformation(txId).do()).assetIndex as bigint | undefined + const assetId = (await algod.pendingTransactionInformation(txId)).assetId as bigint | undefined if (!assetId) { throw new Error('Asset creation transaction confirmed without returning an asset id') } @@ -228,21 +235,21 @@ export async function createDummyAsset(): Promise { export async function createDummyApp(): Promise { const { address, secretKey } = await getSenderAccount() const algod = getAlgodClient() - const sp = await algod.getTransactionParams().do() + const sp = await algod.transactionParams() const approvalProgramSource = '#pragma version 8\nint 1' const clearProgramSource = '#pragma version 8\nint 1' const compile = async (source: string) => { - const result = await algod.compile(source).do() + const result = await algod.tealCompile({ body: source }) return new Uint8Array(Buffer.from(result.result, 'base64')) } const approvalProgram = await compile(approvalProgramSource) const clearProgram = await compile(clearProgramSource) - const firstValid = BigInt(sp.firstValid ?? sp.lastValid) - const lastValid = firstValid + 1_000n + const firstValid = sp.lastRound + const lastValid = sp.lastRound + 1_000n const transaction: Transaction = { transactionType: TransactionType.AppCall, @@ -251,7 +258,7 @@ export async function createDummyApp(): Promise { fee: sp.minFee, lastValid, genesisHash: decodeGenesisHash(sp.genesisHash), - genesisId: sp.genesisID, + genesisId: sp.genesisId, appCall: { appId: 0n, onComplete: OnApplicationComplete.NoOp, @@ -270,7 +277,7 @@ export async function createDummyApp(): Promise { const { txId } = await submitTransaction(transaction, algod, secretKey) - const appId = (await algod.pendingTransactionInformation(txId).do()).applicationIndex + const appId = (await algod.pendingTransactionInformation(txId)).appId if (!appId) { throw new Error('Application creation transaction confirmed without returning an app id') } @@ -284,9 +291,3 @@ export function getIndexerEnv(): IndexerTestConfig { indexerApiToken: process.env.INDEXER_API_TOKEN ?? 'a'.repeat(64), } } - -export async function waitForIndexerTransaction(indexer: IndexerClient, txId: string): Promise { - await runWhenIndexerCaughtUp(async () => { - await indexer.lookupTransaction(txId) - }) -} diff --git a/packages/typescript/algokit_utils/src/index.ts b/packages/typescript/algokit_utils/src/index.ts index 45f299141..9cd4cf408 100644 --- a/packages/typescript/algokit_utils/src/index.ts +++ b/packages/typescript/algokit_utils/src/index.ts @@ -4,5 +4,6 @@ export * from './clients/client-manager' export * from './clients/app-manager' export * from './clients/network-client' export * from './clients/http/retry-http-request' +export * from './testing/indexer' export * from '@algorandfoundation/algokit-transact' diff --git a/packages/typescript/algokit_utils/src/transactions/sender.ts b/packages/typescript/algokit_utils/src/transactions/sender.ts index 3d698ba5e..268a6cc58 100644 --- a/packages/typescript/algokit_utils/src/transactions/sender.ts +++ b/packages/typescript/algokit_utils/src/transactions/sender.ts @@ -138,7 +138,7 @@ export class TransactionSender { (composer, p) => composer.addAssetCreate(p), (baseResult) => { const assetId = baseResult.confirmation.assetId - if (assetId === undefined || assetId <= 0) { + if (assetId === undefined || assetId <= 0n) { throw new Error('Asset creation confirmation missing assetId') } return { @@ -309,7 +309,7 @@ export class TransactionSender { (composer, p) => composer.addAppCreate(p), (baseResult) => { const appId = baseResult.confirmation.appId - if (appId === undefined || appId <= 0) { + if (appId === undefined || appId <= 0n) { throw new Error('App creation confirmation missing appId') } return { @@ -367,7 +367,7 @@ export class TransactionSender { (composer, p) => composer.addAppCreateMethodCall(p), (baseResult) => { const appId = baseResult.result.confirmation.appId - if (appId === undefined || appId <= 0) { + if (appId === undefined || appId <= 0n) { throw new Error('App creation confirmation missing appId') } return { diff --git a/packages/typescript/algokit_utils/tests/clients/fixtures.ts b/packages/typescript/algokit_utils/tests/clients/fixtures.ts index 6555a9149..247d6e743 100644 --- a/packages/typescript/algokit_utils/tests/clients/fixtures.ts +++ b/packages/typescript/algokit_utils/tests/clients/fixtures.ts @@ -1,5 +1,5 @@ import type { AlgodClient } from '@algorandfoundation/algod-client' -import { signTransaction as signTransactionHelper, getSenderAccount } from '../../../algokit_common/tests/helpers' +import { signTransaction as signTransactionHelper, getSenderAccount, waitForConfirmation } from '../../../algokit_common/tests/helpers' import { AssetManager } from '../../src/clients/asset-manager' import { ClientManager } from '../../src/clients/client-manager' import { TransactionComposer } from '../../src/transactions/composer' @@ -8,7 +8,6 @@ import type { AssetCreateParams } from '../../src/transactions/asset-config' import type { AssetTransferParams } from '../../src/transactions/asset-transfer' import type { PaymentParams } from '../../src/transactions/payment' import type { Transaction, SignedTransaction } from '@algorandfoundation/algokit-transact' -import type { PendingTransactionResponse } from '@algorandfoundation/algod-client' import algosdk from 'algosdk' import { randomBytes } from 'crypto' @@ -180,18 +179,3 @@ function createTransactionSigner(secretKey: Uint8Array): TransactionSigner { }, } } - -async function waitForConfirmation(algod: AlgodClient, txId: string, attempts = 30): Promise { - for (let i = 0; i < attempts; i++) { - const pending = await algod.pendingTransactionInformation(txId) - if (pending.confirmedRound !== undefined && pending.confirmedRound > 0n) { - return pending - } - await delay(500) - } - throw new Error(`Transaction ${txId} unconfirmed after ${attempts} attempts`) -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts index 8dee8a689..20d1301c4 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts @@ -1,6 +1,7 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyApp, getIndexerEnv, waitForIndexerTransaction } from '../../../algokit_common/tests/helpers' +import { createDummyApp, getIndexerEnv } from '../../../algokit_common/tests/helpers' +import { runWhenIndexerCaughtUp } from '../../src' describe('Indexer search applications', () => { it('should search for applications', async () => { @@ -9,7 +10,9 @@ describe('Indexer search applications', () => { const env = getIndexerEnv() const client = new IndexerClient({ baseUrl: env.indexerBaseUrl, apiToken: env.indexerApiToken ?? undefined }) - await waitForIndexerTransaction(client, txId) + await runWhenIndexerCaughtUp(async () => { + await client.lookupTransaction(txId) + }) const res = await client.searchForApplications() expect(res).toHaveProperty('applications') diff --git a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts index f003c9e24..8e3289940 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts @@ -1,6 +1,7 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyAsset, getIndexerEnv, waitForIndexerTransaction } from '../../../algokit_common/tests/helpers' +import { createDummyAsset, getIndexerEnv } from '../../../algokit_common/tests/helpers' +import { runWhenIndexerCaughtUp } from '../../src' describe('Indexer search transactions', () => { it('should search for transactions', async () => { @@ -8,7 +9,9 @@ describe('Indexer search transactions', () => { const env = getIndexerEnv() const client = new IndexerClient({ baseUrl: env.indexerBaseUrl, apiToken: env.indexerApiToken ?? undefined }) - await waitForIndexerTransaction(client, txId) + await runWhenIndexerCaughtUp(async () => { + await client.lookupTransaction(txId) + }) const res = await client.searchForTransactions() expect(res).toHaveProperty('transactions') From d9d4fa68b5803921124a242f12040e0f894e1fd6 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 17 Oct 2025 11:07:09 +0200 Subject: [PATCH 15/18] chore: pr comments --- .../src/clients/asset_manager.rs | 29 +++++++++++-------- .../tests/clients/asset_manager.rs | 14 +-------- packages/typescript/algod_client/package.json | 5 ++-- packages/typescript/algokit_abi/package.json | 5 ++-- .../typescript/algokit_common/package.json | 5 ++-- .../typescript/algokit_transact/package.json | 5 ++-- .../typescript/algokit_utils/package.json | 5 ++-- .../typescript/indexer_client/package.json | 5 ++-- packages/typescript/kmd_client/package.json | 5 ++-- 9 files changed, 39 insertions(+), 39 deletions(-) diff --git a/crates/algokit_utils/src/clients/asset_manager.rs b/crates/algokit_utils/src/clients/asset_manager.rs index 2b9121e64..acaf04235 100644 --- a/crates/algokit_utils/src/clients/asset_manager.rs +++ b/crates/algokit_utils/src/clients/asset_manager.rs @@ -276,21 +276,26 @@ impl AssetManager { asset_creators.insert(asset_id, creator); } - let mut bulk_results = Vec::with_capacity(asset_ids.len()); - - for asset_chunk in asset_ids.chunks(MAX_TX_GROUP_SIZE) { - let mut composer = (self.new_composer)(None); - - for &asset_id in asset_chunk { + let asset_creator_pairs: Vec<(u64, Address)> = asset_ids + .iter() + .map(|&asset_id| { let creator = asset_creators - .get(&asset_id) - .cloned() + .remove(&asset_id) .expect("Creator information should be available for all asset IDs"); + (asset_id, creator) + }) + .collect(); + let mut bulk_results = Vec::with_capacity(asset_creator_pairs.len()); + + for asset_chunk in asset_creator_pairs.chunks(MAX_TX_GROUP_SIZE) { + let mut composer = (self.new_composer)(None); + + for (asset_id, creator) in asset_chunk.iter() { let opt_out_params = AssetOptOutParams { sender: account.clone(), - asset_id, - close_remainder_to: Some(creator), + asset_id: *asset_id, + close_remainder_to: Some(creator.clone()), ..Default::default() }; @@ -305,8 +310,8 @@ impl AssetManager { .map_err(|e| AssetManagerError::ComposerError { source: e })?; bulk_results.extend(asset_chunk.iter().zip(composer_result.results.iter()).map( - |(&asset_id, result)| BulkAssetOptInOutResult { - asset_id, + |((asset_id, _), result)| BulkAssetOptInOutResult { + asset_id: *asset_id, transaction_id: result.transaction_id.clone(), }, )); diff --git a/crates/algokit_utils/tests/clients/asset_manager.rs b/crates/algokit_utils/tests/clients/asset_manager.rs index 5915f3529..7b2bf6f31 100644 --- a/crates/algokit_utils/tests/clients/asset_manager.rs +++ b/crates/algokit_utils/tests/clients/asset_manager.rs @@ -313,19 +313,7 @@ async fn test_bulk_opt_out_batches( let asset_manager = algorand_fixture.algorand_client.asset(); - for asset_chunk in asset_ids.chunks(MAX_TX_GROUP_SIZE) { - let mut composer = algorand_fixture.algorand_client.new_composer(None); - for &asset_id in asset_chunk { - let opt_in_params = AssetOptInParams { - sender: test_address.clone(), - signer: Some(Arc::new(test_account.clone())), - asset_id, - ..Default::default() - }; - composer.add_asset_opt_in(opt_in_params)?; - } - composer.send(Default::default()).await?; - } + asset_manager.bulk_opt_in(&test_address, &asset_ids).await?; let results = asset_manager .bulk_opt_out(&test_address, &asset_ids, None) diff --git a/packages/typescript/algod_client/package.json b/packages/typescript/algod_client/package.json index 0256e6c5f..76ccf8418 100644 --- a/packages/typescript/algod_client/package.json +++ b/packages/typescript/algod_client/package.json @@ -25,10 +25,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md dist", "test": "vitest run --coverage --passWithNoTests", diff --git a/packages/typescript/algokit_abi/package.json b/packages/typescript/algokit_abi/package.json index 14bb1fb85..78934b3c7 100644 --- a/packages/typescript/algokit_abi/package.json +++ b/packages/typescript/algokit_abi/package.json @@ -30,10 +30,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md dist", "test": "vitest run --coverage --passWithNoTests", diff --git a/packages/typescript/algokit_common/package.json b/packages/typescript/algokit_common/package.json index cd5cb5311..cae50c095 100644 --- a/packages/typescript/algokit_common/package.json +++ b/packages/typescript/algokit_common/package.json @@ -25,10 +25,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md dist", "test": "vitest run --coverage --passWithNoTests", diff --git a/packages/typescript/algokit_transact/package.json b/packages/typescript/algokit_transact/package.json index 72254df7d..30da9b618 100644 --- a/packages/typescript/algokit_transact/package.json +++ b/packages/typescript/algokit_transact/package.json @@ -25,10 +25,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md dist", "test": "vitest run --coverage --passWithNoTests", diff --git a/packages/typescript/algokit_utils/package.json b/packages/typescript/algokit_utils/package.json index 6051499ef..7a4479e18 100644 --- a/packages/typescript/algokit_utils/package.json +++ b/packages/typescript/algokit_utils/package.json @@ -25,10 +25,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md ../LICENSE dist", "test": "vitest run --coverage --passWithNoTests", diff --git a/packages/typescript/indexer_client/package.json b/packages/typescript/indexer_client/package.json index d3307991a..bc31d053d 100644 --- a/packages/typescript/indexer_client/package.json +++ b/packages/typescript/indexer_client/package.json @@ -25,10 +25,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md dist", "test": "vitest run --coverage --passWithNoTests", diff --git a/packages/typescript/kmd_client/package.json b/packages/typescript/kmd_client/package.json index 2c35ba666..70bd658f3 100644 --- a/packages/typescript/kmd_client/package.json +++ b/packages/typescript/kmd_client/package.json @@ -25,10 +25,11 @@ "./package.json": "./package.json" }, "scripts": { - "build": "run-s lint build:*", + "build": "run-s build:*", "build-watch": "rolldown --watch -c", "build:0-clean": "rimraf dist coverage", - "build:1-compile": "rolldown -c", + "build:1-lint": "npm run lint", + "build:2-compile": "rolldown -c", "build:3-copy-pkg-json": "npx --yes @makerx/ts-toolkit@4.0.0-beta.22 copy-package-json --custom-sections module main type types exports", "build:4-copy-readme": "cpy README.md dist", "test": "vitest run --coverage --passWithNoTests", From a1061fd717fa24bf9b6ebcf8145338c334edd0c3 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 17 Oct 2025 12:45:33 +0200 Subject: [PATCH 16/18] refactor: pr comments --- .../src/clients/asset_manager.rs | 118 +- .../tests/clients/asset_manager.rs | 22 +- .../typescript/algokit_common/package.json | 4 +- .../algokit_common/src/constants.ts | 1 + .../typescript/algokit_common/src/index.ts | 1 + .../typescript/algokit_common/src/mnemonic.ts | 2173 +++++++++++++++++ .../algokit_common/tests/helpers.ts | 293 --- .../algokit_utils/src/clients/app-manager.ts | 53 +- .../src/clients/asset-manager.ts | 138 +- .../src/clients/client-manager.ts | 30 +- .../algokit_utils/src/testing/indexer.ts | 14 + .../algokit_utils/src/transactions/common.ts | 36 + .../src/transactions/composer.ts | 36 +- packages/typescript/algokit_utils/src/util.ts | 61 + .../tests/algod/pendingTransaction.test.ts | 2 +- .../tests/algod/simulateTransactions.test.ts | 2 +- .../tests/algod/transactionParams.test.ts | 2 +- .../tests/clients/asset-manager.test.ts | 54 +- .../tests/clients/client-manager.test.ts | 2 +- .../algokit_utils/tests/clients/fixtures.ts | 181 -- .../algokit_utils/tests/fixtures.ts | 444 ++++ .../tests/indexer/searchApplications.test.ts | 11 +- .../tests/indexer/searchTransactions.test.ts | 11 +- packages/typescript/package-lock.json | 60 +- packages/typescript/package.json | 1 - 25 files changed, 2946 insertions(+), 804 deletions(-) create mode 100644 packages/typescript/algokit_common/src/mnemonic.ts delete mode 100644 packages/typescript/algokit_common/tests/helpers.ts create mode 100644 packages/typescript/algokit_utils/src/util.ts delete mode 100644 packages/typescript/algokit_utils/tests/clients/fixtures.ts create mode 100644 packages/typescript/algokit_utils/tests/fixtures.ts diff --git a/crates/algokit_utils/src/clients/asset_manager.rs b/crates/algokit_utils/src/clients/asset_manager.rs index acaf04235..12df5a950 100644 --- a/crates/algokit_utils/src/clients/asset_manager.rs +++ b/crates/algokit_utils/src/clients/asset_manager.rs @@ -1,8 +1,16 @@ -use algod_client::apis::{AlgodClient, Error as AlgodError}; +use algod_client::apis::{ + AlgodApiError, AlgodClient, Error as AlgodError, + account_asset_information::AccountAssetInformationError, get_asset_by_id::GetAssetByIdError, +}; use algod_client::models::{AccountAssetInformation as AlgodAccountAssetInformation, Asset}; +use algokit_http_client::HttpError; use algokit_transact::{Address, constants::MAX_TX_GROUP_SIZE}; use snafu::Snafu; -use std::{collections::HashMap, str::FromStr, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + str::FromStr, + sync::Arc, +}; use crate::transactions::{ AssetOptInParams, AssetOptOutParams, ComposerError, TransactionComposer, @@ -175,7 +183,10 @@ impl AssetManager { .algod_client .get_asset_by_id(asset_id) .await - .map_err(|e| AssetManagerError::AlgodClientError { source: e })?; + .map_err(|error| { + map_get_asset_by_id_error(&error, asset_id) + .unwrap_or_else(|| AssetManagerError::AlgodClientError { source: error }) + })?; Ok(asset.into()) } @@ -188,10 +199,14 @@ impl AssetManager { sender: &Address, asset_id: u64, ) -> Result { + let sender_str = sender.to_string(); self.algod_client - .account_asset_information(&sender.to_string(), asset_id, None) + .account_asset_information(sender_str.as_str(), asset_id, None) .await - .map_err(|e| AssetManagerError::AlgodClientError { source: e }) + .map_err(|error| { + map_account_asset_information_error(&error, sender_str.as_str(), asset_id) + .unwrap_or_else(|| AssetManagerError::AlgodClientError { source: error }) + }) } pub async fn bulk_opt_in( @@ -203,9 +218,17 @@ impl AssetManager { return Ok(Vec::new()); } - let mut bulk_results = Vec::with_capacity(asset_ids.len()); + // Ignore duplicate asset IDs while preserving input order + let mut seen: HashSet = HashSet::with_capacity(asset_ids.len()); + let unique_ids: Vec = asset_ids + .iter() + .copied() + .filter(|id| seen.insert(*id)) + .collect(); + + let mut bulk_results = Vec::with_capacity(unique_ids.len()); - for asset_chunk in asset_ids.chunks(MAX_TX_GROUP_SIZE) { + for asset_chunk in unique_ids.chunks(MAX_TX_GROUP_SIZE) { let mut composer = (self.new_composer)(None); for &asset_id in asset_chunk { @@ -246,11 +269,19 @@ impl AssetManager { return Ok(Vec::new()); } + // Ignore duplicate asset IDs while preserving input order + let mut seen: HashSet = HashSet::with_capacity(asset_ids.len()); + let unique_ids: Vec = asset_ids + .iter() + .copied() + .filter(|id| seen.insert(*id)) + .collect(); + let should_check_balance = ensure_zero_balance.unwrap_or(false); // If we need to check balances, verify they are all zero if should_check_balance { - for &asset_id in asset_ids { + for &asset_id in unique_ids.iter() { let account_info = self.get_account_information(account, asset_id).await?; let balance = account_info .asset_holding @@ -268,15 +299,15 @@ impl AssetManager { } // Fetch asset information to get creators - let mut asset_creators = HashMap::with_capacity(asset_ids.len()); - for &asset_id in asset_ids { + let mut asset_creators = HashMap::with_capacity(unique_ids.len()); + for &asset_id in unique_ids.iter() { let asset_info = self.get_by_id(asset_id).await?; let creator = Address::from_str(&asset_info.creator) .map_err(|_| AssetManagerError::AssetNotFound { asset_id })?; asset_creators.insert(asset_id, creator); } - let asset_creator_pairs: Vec<(u64, Address)> = asset_ids + let asset_creator_pairs: Vec<(u64, Address)> = unique_ids .iter() .map(|&asset_id| { let creator = asset_creators @@ -321,6 +352,71 @@ impl AssetManager { } } +fn map_get_asset_by_id_error(error: &AlgodError, asset_id: u64) -> Option { + match error { + AlgodError::Api { source } => match source { + AlgodApiError::GetAssetById { error } => match error { + GetAssetByIdError::Status404(_) => { + Some(AssetManagerError::AssetNotFound { asset_id }) + } + _ => None, + }, + _ => None, + }, + AlgodError::Http { source } => http_error_message(source).and_then(|message| { + if message.contains("status 404") { + Some(AssetManagerError::AssetNotFound { asset_id }) + } else { + None + } + }), + _ => None, + } +} + +fn map_account_asset_information_error( + error: &AlgodError, + address: &str, + asset_id: u64, +) -> Option { + match error { + AlgodError::Api { source } => match source { + AlgodApiError::AccountAssetInformation { error } => match error { + AccountAssetInformationError::Status400(_) => { + Some(AssetManagerError::AccountNotFound { + address: address.to_string(), + }) + } + _ => None, + }, + _ => None, + }, + AlgodError::Http { source } => http_error_message(source).and_then(|message| { + if message.contains("status 404") { + Some(AssetManagerError::NotOptedIn { + address: address.to_string(), + asset_id, + }) + } else if message.contains("status 400") + || message.to_ascii_lowercase().contains("account not found") + { + Some(AssetManagerError::AccountNotFound { + address: address.to_string(), + }) + } else { + None + } + }), + _ => None, + } +} + +fn http_error_message(error: &HttpError) -> Option<&str> { + match error { + HttpError::RequestError { message } => Some(message.as_str()), + } +} + #[derive(Debug, Snafu)] pub enum AssetManagerError { #[snafu(display("Algod client error: {source}"))] diff --git a/crates/algokit_utils/tests/clients/asset_manager.rs b/crates/algokit_utils/tests/clients/asset_manager.rs index 7b2bf6f31..c760dbea3 100644 --- a/crates/algokit_utils/tests/clients/asset_manager.rs +++ b/crates/algokit_utils/tests/clients/asset_manager.rs @@ -38,11 +38,13 @@ async fn test_get_asset_by_id_nonexistent( let asset_manager = algorand_fixture.algorand_client.asset(); // Test non-existent asset - let result = asset_manager.get_by_id(999999999).await; - assert!(result.is_err()); + let error = asset_manager + .get_by_id(999_999_999) + .await + .expect_err("expected asset lookup to fail"); assert!(matches!( - result.unwrap_err(), - AssetManagerError::AlgodClientError { source: _ } + error, + AssetManagerError::AssetNotFound { asset_id: 999_999_999 } )); Ok(()) @@ -93,11 +95,15 @@ async fn test_get_account_information_not_opted_in( .get_account_information(&test_account.account().address(), asset_id) .await; - // For non-opted-in accounts, algod returns 404 which becomes an AlgodClientError - assert!(result.is_err()); + // For non-opted-in accounts, we should surface a dedicated NotOptedIn error + let error = result.expect_err("expected account asset lookup to fail"); + let expected_address = test_account.account().address().to_string(); assert!(matches!( - result.unwrap_err(), - AssetManagerError::AlgodClientError { source: _ } + error, + AssetManagerError::NotOptedIn { + ref address, + asset_id: err_asset_id + } if address == &expected_address && err_asset_id == asset_id )); Ok(()) diff --git a/packages/typescript/algokit_common/package.json b/packages/typescript/algokit_common/package.json index cae50c095..7a456b1a4 100644 --- a/packages/typescript/algokit_common/package.json +++ b/packages/typescript/algokit_common/package.json @@ -47,8 +47,6 @@ "@algorandfoundation/algod-client": "../algod_client/dist", "@algorandfoundation/algokit-transact": "../algokit_transact/dist", "@algorandfoundation/indexer-client": "../indexer_client/dist", - "@algorandfoundation/kmd-client": "../kmd_client/dist", - "@noble/ed25519": "^3.0.0", - "algosdk": "^3.5.0" + "@algorandfoundation/kmd-client": "../kmd_client/dist" } } diff --git a/packages/typescript/algokit_common/src/constants.ts b/packages/typescript/algokit_common/src/constants.ts index b3d7b65ce..e3be08552 100644 --- a/packages/typescript/algokit_common/src/constants.ts +++ b/packages/typescript/algokit_common/src/constants.ts @@ -4,6 +4,7 @@ export const MULTISIG_DOMAIN_SEPARATOR = 'MultisigAddr' export const SIGNATURE_ENCODING_INCR = 75 export const HASH_BYTES_LENGTH = 32 export const PUBLIC_KEY_BYTE_LENGTH = 32 +export const SECRET_KEY_BYTE_LENGTH = 64 export const MAX_TX_GROUP_SIZE = 16 export const CHECKSUM_BYTE_LENGTH = 4 export const ADDRESS_LENGTH = 58 diff --git a/packages/typescript/algokit_common/src/index.ts b/packages/typescript/algokit_common/src/index.ts index 69d74498e..7fb96d58b 100644 --- a/packages/typescript/algokit_common/src/index.ts +++ b/packages/typescript/algokit_common/src/index.ts @@ -3,3 +3,4 @@ export * from './array' export * from './constants' export * from './crypto' export * from './expand' +export * from './mnemonic' diff --git a/packages/typescript/algokit_common/src/mnemonic.ts b/packages/typescript/algokit_common/src/mnemonic.ts new file mode 100644 index 000000000..f2072fb8a --- /dev/null +++ b/packages/typescript/algokit_common/src/mnemonic.ts @@ -0,0 +1,2173 @@ +import sha512 from 'js-sha512' +import * as ed from '@noble/ed25519' +import { SECRET_KEY_BYTE_LENGTH } from './constants' +import { concatArrays } from './array' + +const BITS_PER_WORD = 11 +const KEY_LEN_BYTES = 32 +const MNEM_LEN_WORDS = 25 // includes checksum word +const MNEMONIC_DELIM = ' ' + +export const WORDLIST = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo', +] + +const WORD_TO_INDEX = new Map(WORDLIST.map((word, index) => [word, index])) + +export enum MnemonicErrorType { + InvalidKeyLength = 'Invalid key length', + InvalidMnemonicLength = 'Invalid mnemonic length', + InvalidWordsInMnemonic = 'Invalid words in mnemonic', + InvalidChecksum = 'Invalid checksum', +} + +export class MnemonicError extends Error { + public readonly type: MnemonicErrorType + + constructor(type: MnemonicErrorType) { + super(type) + this.type = type + this.name = 'MnemonicError' + } +} + +export function keyToMnemonic(key: Uint8Array): string { + if (!(key instanceof Uint8Array)) { + throw new TypeError('Expected Uint8Array for key') + } + if (key.length !== SECRET_KEY_BYTE_LENGTH) { + throw new MnemonicError(MnemonicErrorType.InvalidKeyLength) + } + const privateKey = key.slice(0, KEY_LEN_BYTES) + const words = toU11Array(privateKey).map(getWord) + words.push(checksum(privateKey)) + return words.join(MNEMONIC_DELIM) +} + +export function mnemonicToKey(mnemonic: string): Uint8Array { + const words = mnemonic.trim().split(/\s+/) + if (words.length !== MNEM_LEN_WORDS) { + throw new MnemonicError(MnemonicErrorType.InvalidMnemonicLength) + } + const checkWord = words.pop()! + const nums = words.map((word) => { + const index = WORD_TO_INDEX.get(word) + if (index === undefined) { + throw new MnemonicError(MnemonicErrorType.InvalidWordsInMnemonic) + } + return index + }) + const bytesWithChecksum = toByteArray(nums) + if (bytesWithChecksum.length !== KEY_LEN_BYTES + 1) { + throw new MnemonicError(MnemonicErrorType.InvalidKeyLength) + } + const privateKey = bytesWithChecksum.slice(0, KEY_LEN_BYTES) + if (checkWord !== checksum(privateKey)) { + throw new MnemonicError(MnemonicErrorType.InvalidChecksum) + } + if (!ed.hashes.sha512) { + ed.hashes.sha512 = (message: Uint8Array) => Uint8Array.from(sha512.sha512.array(message)) + } + const publicKey = ed.getPublicKey(privateKey) + return concatArrays(privateKey, publicKey) +} + +function checksum(data: Uint8Array): string { + const digest = sha512.sha512_256.array(data) + const firstTwo = Uint8Array.from(digest.slice(0, 2)) + const index = toU11Array(firstTwo)[0] + return getWord(index) +} + +function toU11Array(bytes: Uint8Array | number[]): number[] { + let buf = 0 + let bitCount = 0 + const out: number[] = [] + for (const byte of bytes) { + buf |= (byte & 0xff) << bitCount + bitCount += 8 + if (bitCount >= BITS_PER_WORD) { + out.push(buf & 0x7ff) + buf >>= BITS_PER_WORD + bitCount -= BITS_PER_WORD + } + } + if (bitCount !== 0) { + out.push(buf & 0x7ff) + } + return out +} + +function toByteArray(nums: number[]): Uint8Array { + let buf = 0 + let bitCount = 0 + const out: number[] = [] + for (const n of nums) { + buf |= (n & 0x7ff) << bitCount + bitCount += BITS_PER_WORD + while (bitCount >= 8) { + out.push(buf & 0xff) + buf >>= 8 + bitCount -= 8 + } + } + if (bitCount !== 0) { + out.push(buf & 0xff) + } + return Uint8Array.from(out) +} + +function getWord(index: number): string { + const word = WORDLIST[index] + if (word === undefined) { + throw new MnemonicError(MnemonicErrorType.InvalidWordsInMnemonic) + } + return word +} diff --git a/packages/typescript/algokit_common/tests/helpers.ts b/packages/typescript/algokit_common/tests/helpers.ts deleted file mode 100644 index 574c375d2..000000000 --- a/packages/typescript/algokit_common/tests/helpers.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - type Transaction, - type SignedTransaction, - TransactionType, - OnApplicationComplete, - encodeTransaction, - encodeSignedTransaction, - getTransactionId, - groupTransactions as groupTxns, -} from '@algorandfoundation/algokit-transact' -import { KmdClient } from '@algorandfoundation/kmd-client' -import { AlgodClient, PendingTransactionResponse } from '@algorandfoundation/algod-client' -import algosdk from 'algosdk' -import * as ed from '@noble/ed25519' -import { Buffer } from 'node:buffer' - -export interface AlgodTestConfig { - algodBaseUrl: string - algodApiToken?: string - senderMnemonic?: string -} - -export function getAlgodEnv(): AlgodTestConfig { - return { - algodBaseUrl: process.env.ALGOD_BASE_URL ?? 'http://localhost:4001', - // Default token for localnet (Algorand sandbox / Algokit LocalNet) - algodApiToken: process.env.ALGOD_API_TOKEN ?? 'a'.repeat(64), - senderMnemonic: process.env.SENDER_MNEMONIC, - } -} - -export async function waitForConfirmation(algod: AlgodClient, txId: string, attempts = 30): Promise { - for (let i = 0; i < attempts; i++) { - const pending = await algod.pendingTransactionInformation(txId) - if (pending.confirmedRound !== undefined && pending.confirmedRound > 0n) { - return pending - } - await new Promise((resolve) => setTimeout(resolve, 500)) - } - throw new Error(`Transaction ${txId} unconfirmed after ${attempts} attempts`) -} - -export async function getSenderMnemonic(): Promise { - if (process.env.SENDER_MNEMONIC) return process.env.SENDER_MNEMONIC - const kmdBase = process.env.KMD_BASE_URL ?? 'http://localhost:4002' - const kmdToken = process.env.KMD_API_TOKEN ?? 'a'.repeat(64) - const walletPassword = process.env.KMD_WALLET_PASSWORD ?? '' - const preferredWalletName = process.env.KMD_WALLET_NAME ?? 'unencrypted-default-wallet' - - const kmd = new KmdClient({ - baseUrl: kmdBase, - apiToken: kmdToken, - }) - - const walletsResponse = await kmd.listWallets() - const wallets = walletsResponse.wallets ?? [] - if (wallets.length === 0) { - throw new Error('No KMD wallets available') - } - - const wallet = wallets.find((w) => (w.name ?? '').toLowerCase() === preferredWalletName.toLowerCase()) ?? wallets[0] - - const walletId = wallet.id - if (!walletId) { - throw new Error('Wallet returned from KMD does not have an id') - } - - const handleResponse = await kmd.initWalletHandleToken({ - body: { - walletId, - walletPassword, - }, - }) - - const walletHandleToken = handleResponse.walletHandleToken - if (!walletHandleToken) { - throw new Error('Failed to obtain wallet handle token from KMD') - } - - try { - const keysResponse = await kmd.listKeysInWallet({ - body: { - walletHandleToken, - }, - }) - let address = keysResponse.addresses?.[0] - if (!address) { - const generated = await kmd.generateKey({ - body: { - walletHandleToken, - displayMnemonic: false, - }, - }) - address = generated.address ?? undefined - } - - if (!address) { - throw new Error('Unable to determine or generate a wallet key from KMD') - } - - const exportResponse = await kmd.exportKey({ - body: { - walletHandleToken, - walletPassword, - address, - }, - }) - - const exportedKey = exportResponse.privateKey - if (!exportedKey) { - throw new Error('KMD key export did not return a private key') - } - - const secretKey = new Uint8Array(exportedKey) - return algosdk.secretKeyToMnemonic(secretKey) - } finally { - await kmd - .releaseWalletHandleToken({ - body: { - walletHandleToken, - }, - }) - .catch(() => undefined) - } -} - -/** - * Convenience helper: derive the sender account (address + keys) used for tests. - * Returns: - * - address: Algorand address string - * - secretKey: 64-byte Ed25519 secret key (private + public) - * - mnemonic: the 25-word mnemonic - */ -export async function getSenderAccount(): Promise<{ - address: string - secretKey: Uint8Array - mnemonic: string -}> { - const mnemonic = await getSenderMnemonic() - const { addr, sk } = algosdk.mnemonicToSecretKey(mnemonic) // TODO: Remove algosdk dependency - const secretKey = new Uint8Array(sk) - return { address: typeof addr === 'string' ? addr : addr.toString(), secretKey, mnemonic } -} - -export async function signTransaction(transaction: Transaction, secretKey: Uint8Array): Promise { - const encodedTxn = encodeTransaction(transaction) - const signature = await ed.signAsync(encodedTxn, secretKey.slice(0, 32)) - - return { - transaction, - signature, - } -} - -export function groupTransactions(transactions: Transaction[]): Transaction[] { - return groupTxns(transactions) -} - -export interface IndexerTestConfig { - indexerBaseUrl: string - indexerApiToken?: string -} - -export interface CreatedAssetInfo { - assetId: bigint - txId: string -} - -export interface CreatedAppInfo { - appId: bigint - txId: string -} - -function getAlgodClient(): AlgodClient { - const env = getAlgodEnv() - return new AlgodClient({ baseUrl: env.algodBaseUrl, apiToken: env.algodApiToken }) -} - -function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { - if (genesisHash instanceof Uint8Array) { - return new Uint8Array(genesisHash) - } - return new Uint8Array(Buffer.from(genesisHash, 'base64')) -} - -async function submitTransaction(transaction: Transaction, algod: AlgodClient, secretKey: Uint8Array): Promise<{ txId: string }> { - const signed = await signTransaction(transaction, secretKey) - const raw = encodeSignedTransaction(signed) - const txId = getTransactionId(transaction) - await algod.rawTransaction({ body: raw }) - await waitForConfirmation(algod, txId, 10) - return { txId } -} - -export async function createDummyAsset(): Promise { - const { address, secretKey } = await getSenderAccount() - const algod = getAlgodClient() - const sp = await algod.transactionParams() - - const firstValid = sp.lastRound - const lastValid = firstValid + 1_000n - - const transaction: Transaction = { - transactionType: TransactionType.AssetConfig, - sender: address, - firstValid, - lastValid, - genesisHash: decodeGenesisHash(sp.genesisHash), - genesisId: sp.genesisId, - fee: sp.minFee, - assetConfig: { - assetId: 0n, - total: 1_000_000n, - decimals: 0, - defaultFrozen: false, - assetName: 'DummyAsset', - unitName: 'DUM', - manager: address, - reserve: address, - freeze: address, - clawback: address, - }, - } - - const { txId } = await submitTransaction(transaction, algod, secretKey) - - const assetId = (await algod.pendingTransactionInformation(txId)).assetId as bigint | undefined - if (!assetId) { - throw new Error('Asset creation transaction confirmed without returning an asset id') - } - - return { assetId, txId } -} - -export async function createDummyApp(): Promise { - const { address, secretKey } = await getSenderAccount() - const algod = getAlgodClient() - const sp = await algod.transactionParams() - - const approvalProgramSource = '#pragma version 8\nint 1' - const clearProgramSource = '#pragma version 8\nint 1' - - const compile = async (source: string) => { - const result = await algod.tealCompile({ body: source }) - return new Uint8Array(Buffer.from(result.result, 'base64')) - } - - const approvalProgram = await compile(approvalProgramSource) - const clearProgram = await compile(clearProgramSource) - - const firstValid = sp.lastRound - const lastValid = sp.lastRound + 1_000n - - const transaction: Transaction = { - transactionType: TransactionType.AppCall, - sender: address, - firstValid, - fee: sp.minFee, - lastValid, - genesisHash: decodeGenesisHash(sp.genesisHash), - genesisId: sp.genesisId, - appCall: { - appId: 0n, - onComplete: OnApplicationComplete.NoOp, - approvalProgram, - clearStateProgram: clearProgram, - globalStateSchema: { - numUints: 1, - numByteSlices: 1, - }, - localStateSchema: { - numUints: 0, - numByteSlices: 0, - }, - }, - } - - const { txId } = await submitTransaction(transaction, algod, secretKey) - - const appId = (await algod.pendingTransactionInformation(txId)).appId - if (!appId) { - throw new Error('Application creation transaction confirmed without returning an app id') - } - - return { appId, txId } -} - -export function getIndexerEnv(): IndexerTestConfig { - return { - indexerBaseUrl: process.env.INDEXER_BASE_URL ?? 'http://localhost:8980', - indexerApiToken: process.env.INDEXER_API_TOKEN ?? 'a'.repeat(64), - } -} diff --git a/packages/typescript/algokit_utils/src/clients/app-manager.ts b/packages/typescript/algokit_utils/src/clients/app-manager.ts index d5cd48c63..785a3cdf7 100644 --- a/packages/typescript/algokit_utils/src/clients/app-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/app-manager.ts @@ -2,6 +2,7 @@ import sha512 from 'js-sha512' import { getAppAddress } from '@algorandfoundation/algokit-common' import { AlgodClient, TealKeyValueStore } from '@algorandfoundation/algod-client' import { Buffer } from 'buffer' +import { bytesToBase64, bytesToUtf8, ensureDecodedBytes, toBytes } from '../util' export enum TealTemplateValueType { Int = 'int', @@ -139,8 +140,8 @@ export class AppManager { return { appId, appAddress: getAppAddress(appId), - approvalProgram: AppManager.toBytes(app.params.approvalProgram), - clearStateProgram: AppManager.toBytes(app.params.clearStateProgram), + approvalProgram: toBytes(app.params.approvalProgram), + clearStateProgram: toBytes(app.params.clearStateProgram), creator: app.params.creator, localInts: Number(app.params.localStateSchema?.numUint ?? 0), localByteSlices: Number(app.params.localStateSchema?.numByteSlice ?? 0), @@ -172,8 +173,8 @@ export class AppManager { const nameRaw = new Uint8Array(b.name) return { nameRaw, - nameBase64: AppManager.bytesToBase64(nameRaw), - name: AppManager.bytesToUtf8(nameRaw), + nameBase64: bytesToBase64(nameRaw), + name: bytesToUtf8(nameRaw), } }) } @@ -182,7 +183,7 @@ export class AppManager { // Algod expects goal-arg style encoding for box name query param in 'encoding:value'. // However our HTTP client decodes base64 automatically into bytes for the Box model fields. // The API still requires 'b64:' for the query parameter value. - const processedBoxName = `b64:${AppManager.bytesToBase64(boxName)}` + const processedBoxName = `b64:${bytesToBase64(boxName)}` const boxResult = await this.algodClient.getApplicationBoxByName(appId, { name: processedBoxName, @@ -198,38 +199,18 @@ export class AppManager { return values } - private static ensureDecodedBytes(bytes: Uint8Array): Uint8Array { - try { - const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const str = buffer.toString('utf8') - if ( - str.length > 0 && - /^[A-Za-z0-9+/]*={0,2}$/.test(str) && - (str.includes('=') || str.includes('+') || str.includes('/') || (str.length % 4 === 0 && str.length >= 8)) - ) { - const decoded = Buffer.from(str, 'base64') - if (!decoded.equals(buffer)) { - return new Uint8Array(decoded) - } - } - } catch { - // Not valid UTF-8 or base64, return as-is - } - return bytes - } - static decodeAppState(state: TealKeyValueStore): Record { const stateValues: Record = {} for (const stateVal of state) { - const keyRaw = AppManager.toBytes(stateVal.key) + const keyRaw = toBytes(stateVal.key) const keyBase64 = stateVal.key const keyString = keyBase64 // TODO: we will need to update the algod client to return int here if (stateVal.value.type === 1n) { - const valueRaw = AppManager.ensureDecodedBytes(new Uint8Array(stateVal.value.bytes)) - const valueBase64 = AppManager.bytesToBase64(valueRaw) + const valueRaw = ensureDecodedBytes(new Uint8Array(stateVal.value.bytes)) + const valueBase64 = bytesToBase64(valueRaw) let valueStr: string try { valueStr = new TextDecoder('utf-8', { fatal: true }).decode(valueRaw) @@ -346,22 +327,6 @@ export class AppManager { return /[a-zA-Z0-9_]/.test(ch) } - private static toBytes(value: Uint8Array | string): Uint8Array { - if (typeof value === 'string') { - return Uint8Array.from(Buffer.from(value, 'base64')) - } - - return new Uint8Array(value) - } - - private static bytesToBase64(value: Uint8Array): string { - return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64') - } - - private static bytesToUtf8(value: Uint8Array): string { - return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('utf-8') - } - static replaceTealTemplateDeployTimeControlParams(tealTemplateCode: string, params: DeploymentMetadata): string { let result = tealTemplateCode diff --git a/packages/typescript/algokit_utils/src/clients/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index 2b958b4ff..8b3081275 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -2,20 +2,7 @@ import { AccountAssetInformation, AlgodClient, ApiError } from '@algorandfoundat import { AssetOptInParams, AssetOptOutParams } from '../transactions/asset-transfer' import { TransactionComposer } from '../transactions/composer' import { MAX_TX_GROUP_SIZE } from '@algorandfoundation/algokit-common' - -const chunkArray = (items: T[], size: number): T[][] => { - if (size <= 0) { - throw new Error('Chunk size must be greater than zero') - } - if (items.length <= size) { - return [items.slice()] - } - const chunks: T[][] = [] - for (let index = 0; index < items.length; index += size) { - chunks.push(items.slice(index, index + size)) - } - return chunks -} +import { chunkArray, createError } from '../util' /** Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets. */ export interface BulkAssetOptInOutResult { @@ -147,29 +134,6 @@ export interface AssetInformation { metadataHash?: Uint8Array } -export type AssetManagerErrorCode = - | 'ALGOD_CLIENT_ERROR' - | 'COMPOSER_ERROR' - | 'ASSET_NOT_FOUND' - | 'ACCOUNT_NOT_FOUND' - | 'NOT_OPTED_IN' - | 'NON_ZERO_BALANCE' - -export class AssetManagerError extends Error { - readonly code: AssetManagerErrorCode - readonly details?: Record - - constructor(code: AssetManagerErrorCode, message: string, details?: Record, cause?: unknown) { - super(message) - this.name = 'AssetManagerError' - this.code = code - this.details = details - if (cause !== undefined) { - ;(this as { cause?: unknown }).cause = cause - } - } -} - /** Manages Algorand Standard Assets. */ export class AssetManager { private readonly algodClient: AlgodClient @@ -207,9 +171,9 @@ export class AssetManager { } } catch (error) { if (error instanceof ApiError && error.status === 404) { - throw new AssetManagerError('ASSET_NOT_FOUND', `Asset not found: ${assetId}`, { assetId }, error) + throw createError(`Asset not found: ${assetId}`, error) } - throw new AssetManagerError('ALGOD_CLIENT_ERROR', 'Failed to fetch asset information', { assetId }, error) + throw createError(`Failed to fetch asset information for asset ${assetId}`, error) } } @@ -223,21 +187,13 @@ export class AssetManager { } catch (error) { if (error instanceof ApiError) { if (error.status === 404) { - throw new AssetManagerError( - 'NOT_OPTED_IN', - `Account ${sender} is not opted into asset ${assetId}`, - { - sender, - assetId, - }, - error, - ) + throw createError(`Account ${sender} is not opted into asset ${assetId}`, error) } if (error.status === 400) { - throw new AssetManagerError('ACCOUNT_NOT_FOUND', `Account not found: ${sender}`, { sender }, error) + throw createError(`Account not found: ${sender}`, error) } } - throw new AssetManagerError('ALGOD_CLIENT_ERROR', 'Failed to fetch account asset information', { sender, assetId }, error) + throw createError(`Failed to fetch account asset information for account ${sender} and asset ${assetId}`, error) } } @@ -246,9 +202,12 @@ export class AssetManager { return [] } + // Ignore duplicate asset IDs while preserving input order + const uniqueIds = [...new Set(assetIds)] + const results: BulkAssetOptInOutResult[] = [] - for (const batch of chunkArray(assetIds, MAX_TX_GROUP_SIZE)) { + for (const batch of chunkArray(uniqueIds, MAX_TX_GROUP_SIZE)) { const composer = this.newComposer() for (const assetId of batch) { @@ -260,7 +219,7 @@ export class AssetManager { try { composer.addAssetOptIn(params) } catch (error) { - throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-in for asset ${assetId}`, { assetId }, error) + throw createError(`Failed to add opt-in for asset ${assetId}`, error) } } @@ -268,10 +227,7 @@ export class AssetManager { const result = await composer.send() if (result.results.length !== batch.length) { - throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { - expected: batch.length, - actual: result.results.length, - }) + throw new Error(`Composer returned an unexpected number of results (expected ${batch.length}, actual ${result.results.length})`) } batch.forEach((assetId, index) => { @@ -281,10 +237,7 @@ export class AssetManager { }) }) } catch (error) { - if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { - throw error - } - throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-in transactions', undefined, error) + throw createError('Failed to submit opt-in transactions', error) } } @@ -296,86 +249,65 @@ export class AssetManager { return [] } + // Ignore duplicate asset IDs while preserving input order + const uniqueIds = [...new Set(assetIds)] + const shouldCheckBalance = ensureZeroBalance ?? false const results: BulkAssetOptInOutResult[] = [] if (shouldCheckBalance) { - for (const assetId of assetIds) { - const accountInfo = await this.getAccountInformation(account, assetId).catch((error: unknown) => { - if (error instanceof AssetManagerError && error.code === 'NOT_OPTED_IN') { - throw new AssetManagerError( - 'NOT_OPTED_IN', - `Account ${account} is not opted into asset ${assetId}`, - { - account, - assetId, - }, - error, - ) - } - throw error - }) + for (const assetId of uniqueIds) { + const accountInfo = await this.getAccountInformation(account, assetId) const balance = accountInfo.assetHolding?.amount ?? 0n if (balance > 0n) { - throw new AssetManagerError('NON_ZERO_BALANCE', `Account ${account} has non-zero balance for asset ${assetId}`, { - account, - assetId, - balance, - }) + throw new Error(`Account ${account} has non-zero balance (${balance}) for asset ${assetId}`) } } } + // Precompute creator cache for all assetIds before batching const creatorCache = new Map() + for (const assetId of uniqueIds) { + const assetInfo = await this.getById(assetId) + creatorCache.set(assetId, assetInfo.creator) + } - for (const batch of chunkArray(assetIds, MAX_TX_GROUP_SIZE)) { - const composer = this.newComposer() + // Prepare stable pairs to preserve input order + const assetCreatorPairs = uniqueIds.map((assetId) => [assetId, creatorCache.get(assetId)!] as const) - const creators: string[] = [] - for (const assetId of batch) { - if (!creatorCache.has(assetId)) { - const assetInfo = await this.getById(assetId) - creatorCache.set(assetId, assetInfo.creator) - } - creators.push(creatorCache.get(assetId)!) - } + for (const batch of chunkArray(assetCreatorPairs, MAX_TX_GROUP_SIZE)) { + const composer = this.newComposer() - batch.forEach((assetId, index) => { + for (const [assetId, creator] of batch) { const params: AssetOptOutParams = { sender: account, assetId, - closeRemainderTo: creators[index], + closeRemainderTo: creator, } try { composer.addAssetOptOut(params) } catch (error) { - throw new AssetManagerError('COMPOSER_ERROR', `Failed to add opt-out for asset ${assetId}`, { assetId }, error) + throw createError(`Failed to add opt-out for asset ${assetId}`, error) } - }) + } try { const result = await composer.send() if (result.results.length !== batch.length) { - throw new AssetManagerError('COMPOSER_ERROR', 'Composer returned an unexpected number of results', { - expected: batch.length, - actual: result.results.length, - }) + throw new Error(`Composer returned an unexpected number of results (expected ${batch.length}, actual ${result.results.length})`) } - batch.forEach((assetId, index) => { + batch.forEach(([assetId], index) => { results.push({ assetId, transactionId: result.results[index].transactionId, }) }) } catch (error) { - if (error instanceof AssetManagerError && error.code === 'COMPOSER_ERROR') { - throw error - } - throw new AssetManagerError('COMPOSER_ERROR', 'Failed to submit opt-out transactions', undefined, error) + throw createError('Failed to submit opt-out transactions', error) } } diff --git a/packages/typescript/algokit_utils/src/clients/client-manager.ts b/packages/typescript/algokit_utils/src/clients/client-manager.ts index ebe31b477..20e4892dc 100644 --- a/packages/typescript/algokit_utils/src/clients/client-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/client-manager.ts @@ -33,20 +33,6 @@ type HttpClientFactoryResult = { type HttpClientFactory = (config: AlgoClientConfig, defaultHeaderName: string) => HttpClientFactoryResult -export type ClientManagerErrorCode = 'INDEXER_NOT_CONFIGURED' | 'KMD_NOT_CONFIGURED' | 'ENVIRONMENT_MISSING' - -export class ClientManagerError extends Error { - readonly code: ClientManagerErrorCode - readonly details?: Record - - constructor(code: ClientManagerErrorCode, message: string, details?: Record) { - super(message) - this.name = 'ClientManagerError' - this.code = code - this.details = details - } -} - export class ClientManager { private static httpClientFactory?: HttpClientFactory private readonly algodClient: AlgodClient @@ -77,7 +63,7 @@ export class ClientManager { /** Returns an Indexer API client or throws if not configured. */ get indexer(): IndexerClient { if (!this.indexerClient) { - throw new ClientManagerError('INDEXER_NOT_CONFIGURED', 'Attempt to use Indexer client without configuring one') + throw new Error('Attempt to use Indexer client without configuring one') } return this.indexerClient } @@ -90,7 +76,7 @@ export class ClientManager { /** Returns a KMD API client or throws if not configured. */ get kmd(): KmdClient { if (!this.kmdClient) { - throw new ClientManagerError('KMD_NOT_CONFIGURED', 'Attempt to use KMD client without configuring one') + throw new Error('Attempt to use KMD client without configuring one') } return this.kmdClient } @@ -250,9 +236,7 @@ export class ClientManager { static getIndexerConfigFromEnvironment(): AlgoClientConfig { const server = process.env.INDEXER_SERVER if (!server) { - throw new ClientManagerError('ENVIRONMENT_MISSING', 'INDEXER_SERVER environment variable not found', { - variable: 'INDEXER_SERVER', - }) + throw new Error('INDEXER_SERVER environment variable not found') } const port = this.parsePort(process.env.INDEXER_PORT) @@ -272,9 +256,7 @@ export class ClientManager { static getAlgodConfigFromEnvironment(): AlgoClientConfig { const server = process.env.ALGOD_SERVER if (!server) { - throw new ClientManagerError('ENVIRONMENT_MISSING', 'ALGOD_SERVER environment variable not found', { - variable: 'ALGOD_SERVER', - }) + throw new Error('ALGOD_SERVER environment variable not found') } const port = this.parsePort(process.env.ALGOD_PORT) @@ -295,9 +277,7 @@ export class ClientManager { static getKmdConfigFromEnvironment(fallbackAlgodConfig?: AlgoClientConfig): AlgoClientConfig { const server = process.env.KMD_SERVER ?? fallbackAlgodConfig?.server ?? process.env.ALGOD_SERVER if (!server) { - throw new ClientManagerError('ENVIRONMENT_MISSING', 'KMD_SERVER environment variable not found', { - variable: 'KMD_SERVER', - }) + throw new Error('KMD_SERVER environment variable not found') } const port = this.parsePort(process.env.KMD_PORT) ?? fallbackAlgodConfig?.port ?? this.parsePort(process.env.ALGOD_PORT) ?? 4002 diff --git a/packages/typescript/algokit_utils/src/testing/indexer.ts b/packages/typescript/algokit_utils/src/testing/indexer.ts index 0aa82c5df..1859cfa09 100644 --- a/packages/typescript/algokit_utils/src/testing/indexer.ts +++ b/packages/typescript/algokit_utils/src/testing/indexer.ts @@ -1,3 +1,5 @@ +import { IndexerClient } from '@algorandfoundation/indexer-client' + /** * Runs the given indexer call until a 404 error is no longer returned. * Tried every 200ms up to 100 times. @@ -33,3 +35,15 @@ export async function runWhenIndexerCaughtUp(run: () => Promise): Promise< return result as T } + +/** + * Waits for the given transaction to be indexed by the indexer. + * @param indexer The indexer client + * @param txId The transaction ID + * @returns The transaction + */ +export async function waitForIndexerTransaction(indexer: IndexerClient, txId: string): Promise { + await runWhenIndexerCaughtUp(async () => { + await indexer.lookupTransaction(txId) + }) +} diff --git a/packages/typescript/algokit_utils/src/transactions/common.ts b/packages/typescript/algokit_utils/src/transactions/common.ts index 534f618df..90f70e748 100644 --- a/packages/typescript/algokit_utils/src/transactions/common.ts +++ b/packages/typescript/algokit_utils/src/transactions/common.ts @@ -27,6 +27,7 @@ import { OnlineKeyRegistrationComposerTransaction, } from './key-registration' import { AccountCloseComposerTransaction, PaymentComposerTransaction } from './payment' +import { AlgodClient, ApiError, PendingTransactionResponse } from '@algorandfoundation/algod-client' export type TransactionComposerTransaction = { type: ComposerTransactionType.Transaction; data: Transaction } export type TransactionWithSignerComposerTransaction = { type: ComposerTransactionType.TransactionWithSigner; data: TransactionWithSigner } @@ -223,3 +224,38 @@ export interface TransactionSigner { export interface SignerGetter { getSigner(address: string): TransactionSigner } + +export async function waitForConfirmation( + algodClient: AlgodClient, + txId: string, + maxRoundsToWait: number, +): Promise { + const status = await algodClient.getStatus() + const startRound = status.lastRound + 1n + let currentRound = startRound + while (currentRound < startRound + BigInt(maxRoundsToWait)) { + try { + const pendingInfo = await algodClient.pendingTransactionInformation(txId) + const confirmedRound = pendingInfo.confirmedRound + if (confirmedRound !== undefined && confirmedRound > 0n) { + return pendingInfo + } else { + const poolError = pendingInfo.poolError + if (poolError !== undefined && poolError.length > 0) { + // If there was a pool error, then the transaction has been rejected! + throw new Error(`Transaction ${txId} was rejected; pool error: ${poolError}`) + } + } + } catch (e: unknown) { + if (e instanceof ApiError && e.status === 404) { + currentRound++ + continue + } + } + + await algodClient.waitForBlock(currentRound) + currentRound++ + } + + throw new Error(`Transaction ${txId} unconfirmed after ${maxRoundsToWait} rounds`) +} diff --git a/packages/typescript/algokit_utils/src/transactions/composer.ts b/packages/typescript/algokit_utils/src/transactions/composer.ts index 0b680ccba..205243bcd 100644 --- a/packages/typescript/algokit_utils/src/transactions/composer.ts +++ b/packages/typescript/algokit_utils/src/transactions/composer.ts @@ -25,7 +25,6 @@ import { } from '@algorandfoundation/algokit-transact' import { AlgodClient, - ApiError, type ApplicationLocalReference, type AssetHoldingReference, type BoxReference, @@ -95,6 +94,7 @@ import { TransactionSigner, TransactionWithSigner, TransactionWithSignerComposerTransaction, + waitForConfirmation, } from './common' import { FeeDelta, FeePriority } from './fee-coverage' import { @@ -467,7 +467,7 @@ export class TransactionComposer { transaction = buildNonParticipationKeyRegistration(ctxn.data, header) break default: - throw new Error('Unsupported transaction type encountered while building transaction group') + throw new Error(`Unsupported transaction type: ${(ctxn as { type: ComposerTransactionType }).type}`) } if (calculateFee) { @@ -810,7 +810,7 @@ export class TransactionComposer { const confirmations = new Array() if (params?.maxRoundsToWaitForConfirmation) { for (const id of transactionIds) { - const confirmation = await this.waitForConfirmation(id, waitRounds) + const confirmation = await waitForConfirmation(this.algodClient, id, waitRounds) confirmations.push(confirmation) } } @@ -837,36 +837,6 @@ export class TransactionComposer { return this.transactions.length } - private async waitForConfirmation(txId: string, maxRoundsToWait: number): Promise { - const status = await this.algodClient.getStatus() - const startRound = status.lastRound + 1n - let currentRound = startRound - while (currentRound < startRound + BigInt(maxRoundsToWait)) { - try { - const pendingInfo = await this.algodClient.pendingTransactionInformation(txId) - const confirmedRound = pendingInfo.confirmedRound - if (confirmedRound !== undefined && confirmedRound > 0n) { - return pendingInfo - } else { - const poolError = pendingInfo.poolError - if (poolError !== undefined && poolError.length > 0) { - // If there was a pool error, then the transaction has been rejected! - throw new Error(`Transaction ${txId} was rejected; pool error: ${poolError}`) - } - } - } catch (e: unknown) { - if (e instanceof ApiError && e.status === 404) { - currentRound++ - continue - } - } - await this.algodClient.waitForBlock(currentRound) - currentRound++ - } - - throw new Error(`Transaction ${txId} unconfirmed after ${maxRoundsToWait} rounds`) - } - private parseAbiReturnValues(confirmations: PendingTransactionResponse[]): (ABIReturn | undefined)[] { const abiReturns = new Array() diff --git a/packages/typescript/algokit_utils/src/util.ts b/packages/typescript/algokit_utils/src/util.ts new file mode 100644 index 000000000..734cbcea3 --- /dev/null +++ b/packages/typescript/algokit_utils/src/util.ts @@ -0,0 +1,61 @@ +/** + * Returns the given array split into chunks of `batchSize` batches. + * @param array The array to chunk + * @param batchSize The size of batches to split the array into + * @returns A generator that yields the array split into chunks of `batchSize` batches + */ +import { Buffer } from 'buffer' + +export function* chunkArray(array: T[], batchSize: number): Generator { + for (let i = 0; i < array.length; i += batchSize) yield array.slice(i, i + batchSize) +} + +/** + * Creates a standard `Error` instance with an optional `cause` for broader runtime support. + * @param message The error message + * @param cause Optional underlying cause to attach to the error + * @returns An Error instance with the supplied message and optional cause + */ +export function createError(message: string, cause?: unknown): Error { + const error = new Error(message) + if (cause !== undefined) { + ;(error as { cause?: unknown }).cause = cause + } + return error +} + +export function toBytes(value: Uint8Array | string): Uint8Array { + if (typeof value === 'string') { + return Uint8Array.from(Buffer.from(value, 'base64')) + } + + return new Uint8Array(value) +} + +export function bytesToBase64(value: Uint8Array): string { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64') +} + +export function bytesToUtf8(value: Uint8Array): string { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('utf-8') +} + +export function ensureDecodedBytes(bytes: Uint8Array): Uint8Array { + try { + const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const str = buffer.toString('utf8') + if ( + str.length > 0 && + /^[A-Za-z0-9+/]*={0,2}$/.test(str) && + (str.includes('=') || str.includes('+') || str.includes('/') || (str.length % 4 === 0 && str.length >= 8)) + ) { + const decoded = Buffer.from(str, 'base64') + if (!decoded.equals(buffer)) { + return new Uint8Array(decoded) + } + } + } catch { + // Not valid UTF-8 or base64, return as-is + } + return bytes +} diff --git a/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts b/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts index 852d0143e..4ba524c08 100644 --- a/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts +++ b/packages/typescript/algokit_utils/tests/algod/pendingTransaction.test.ts @@ -1,7 +1,7 @@ import { expect, it, describe } from 'vitest' import { AlgodClient, PendingTransactionResponse } from '@algorandfoundation/algod-client' import { encodeSignedTransaction, getTransactionId, TransactionType, type Transaction } from '@algorandfoundation/algokit-transact' -import { getAlgodEnv, getSenderAccount, signTransaction } from '../../../algokit_common/tests/helpers' +import { getAlgodEnv, getSenderAccount, signTransaction } from '../fixtures' describe('Algod pendingTransaction', () => { it('submits a payment tx and queries pending info', async () => { diff --git a/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts b/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts index 9ac61086b..29f717cf8 100644 --- a/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts +++ b/packages/typescript/algokit_utils/tests/algod/simulateTransactions.test.ts @@ -1,7 +1,7 @@ import { expect, it, describe } from 'vitest' import { AlgodClient, ClientConfig, SimulateRequest } from '@algorandfoundation/algod-client' import { TransactionType, type SignedTransaction, type Transaction } from '@algorandfoundation/algokit-transact' -import { getAlgodEnv, getSenderAccount, groupTransactions, signTransaction } from '../../../algokit_common/tests/helpers' +import { getAlgodEnv, getSenderAccount, groupTransactions, signTransaction } from '../fixtures' describe('simulateTransactions', () => { it('should simulate two transactions and decode msgpack response', async () => { diff --git a/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts b/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts index 584b8fe54..2d90042bf 100644 --- a/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts +++ b/packages/typescript/algokit_utils/tests/algod/transactionParams.test.ts @@ -1,6 +1,6 @@ import { expect, it, describe } from 'vitest' import { AlgodClient } from '@algorandfoundation/algod-client' -import { getAlgodEnv } from '../../../algokit_common/tests/helpers' +import { getAlgodEnv } from '../fixtures' describe('transactionParams', () => { it('should fetch transaction params', async () => { diff --git a/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts b/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts index 6e097a296..57eb347b8 100644 --- a/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts +++ b/packages/typescript/algokit_utils/tests/clients/asset-manager.test.ts @@ -1,6 +1,6 @@ import { MAX_TX_GROUP_SIZE } from '@algorandfoundation/algokit-common' import { describe, expect, it } from 'vitest' -import { createAssetTestContext, createFundedAccount, createTestAsset, transferAsset } from './fixtures' +import { createAlgorandTestContext, createFundedAccount, createTestAsset, transferAsset } from '../fixtures' const TEST_TIMEOUT = 120_000 @@ -8,8 +8,8 @@ describe.sequential('AssetManager integration', () => { it( 'retrieves asset information by id', async () => { - const context = await createAssetTestContext() - const assetId = await createTestAsset(context, { assetName: 'AssetManager E2E', unitName: 'AME2E' }) + const context = await createAlgorandTestContext() + const { assetId } = await createTestAsset(context, { assetName: 'AssetManager E2E', unitName: 'AME2E' }) const info = await context.assetManager.getById(assetId) @@ -26,10 +26,10 @@ describe.sequential('AssetManager integration', () => { it( 'maps missing assets to ASSET_NOT_FOUND errors', async () => { - const context = await createAssetTestContext() + const context = await createAlgorandTestContext() await expect(context.assetManager.getById(9_999_999_999n)).rejects.toMatchObject({ - code: 'ASSET_NOT_FOUND', + message: 'Asset not found: 9999999999', }) }, TEST_TIMEOUT, @@ -38,8 +38,8 @@ describe.sequential('AssetManager integration', () => { it( 'retrieves account holdings for opted-in creator', async () => { - const context = await createAssetTestContext() - const assetId = await createTestAsset(context) + const context = await createAlgorandTestContext() + const { assetId } = await createTestAsset(context) const accountInfo = await context.assetManager.getAccountInformation(context.creator.address, assetId) @@ -52,12 +52,12 @@ describe.sequential('AssetManager integration', () => { it( 'raises NOT_OPTED_IN when account has not opted in', async () => { - const context = await createAssetTestContext() - const assetId = await createTestAsset(context) + const context = await createAlgorandTestContext() + const { assetId } = await createTestAsset(context) const account = await createFundedAccount(context) await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ - code: 'NOT_OPTED_IN', + message: expect.stringContaining('is not opted into asset'), }) }, TEST_TIMEOUT, @@ -66,8 +66,8 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt in opts into each requested asset', async () => { - const context = await createAssetTestContext() - const assets = await Promise.all([createTestAsset(context), createTestAsset(context)]) + const context = await createAlgorandTestContext() + const assets = (await Promise.all([createTestAsset(context), createTestAsset(context)])).map((a) => a.assetId) const account = await createFundedAccount(context) const results = await context.assetManager.bulkOptIn(account.address, assets) @@ -86,11 +86,11 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt in splits batches above the max group size', async () => { - const context = await createAssetTestContext() + const context = await createAlgorandTestContext() const assetCount = MAX_TX_GROUP_SIZE + 3 const assetIds: bigint[] = [] for (let i = 0; i < assetCount; i++) { - assetIds.push(await createTestAsset(context)) + assetIds.push((await createTestAsset(context)).assetId) } const account = await createFundedAccount(context) @@ -105,7 +105,7 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt in returns an empty collection when no assets provided', async () => { - const context = await createAssetTestContext() + const context = await createAlgorandTestContext() const account = await createFundedAccount(context) const results = await context.assetManager.bulkOptIn(account.address, []) @@ -118,8 +118,8 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt out removes holdings and closes to the creator', async () => { - const context = await createAssetTestContext() - const assetIds = await Promise.all([createTestAsset(context), createTestAsset(context)]) + const context = await createAlgorandTestContext() + const assetIds = (await Promise.all([createTestAsset(context), createTestAsset(context)])).map((a) => a.assetId) const account = await createFundedAccount(context) await context.assetManager.bulkOptIn(account.address, assetIds) @@ -129,7 +129,7 @@ describe.sequential('AssetManager integration', () => { expect(results).toHaveLength(assetIds.length) for (const assetId of assetIds) { await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ - code: 'NOT_OPTED_IN', + message: expect.stringContaining('is not opted into asset'), }) } }, @@ -139,11 +139,11 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt out splits batches appropriately', async () => { - const context = await createAssetTestContext() + const context = await createAlgorandTestContext() const assetCount = MAX_TX_GROUP_SIZE + 2 const assetIds: bigint[] = [] for (let i = 0; i < assetCount; i++) { - assetIds.push(await createTestAsset(context)) + assetIds.push((await createTestAsset(context)).assetId) } const account = await createFundedAccount(context) @@ -160,7 +160,7 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt out returns an empty collection for empty requests', async () => { - const context = await createAssetTestContext() + const context = await createAlgorandTestContext() const account = await createFundedAccount(context) const results = await context.assetManager.bulkOptOut(account.address, [], true) @@ -173,8 +173,8 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt out rejects when balance check detects non-zero balance', async () => { - const context = await createAssetTestContext() - const assetId = await createTestAsset(context) + const context = await createAlgorandTestContext() + const { assetId } = await createTestAsset(context) const account = await createFundedAccount(context) await context.assetManager.bulkOptIn(account.address, [assetId]) @@ -186,7 +186,7 @@ describe.sequential('AssetManager integration', () => { }) await expect(context.assetManager.bulkOptOut(account.address, [assetId], true)).rejects.toMatchObject({ - code: 'NON_ZERO_BALANCE', + message: expect.stringContaining('has non-zero balance'), }) }, TEST_TIMEOUT, @@ -195,8 +195,8 @@ describe.sequential('AssetManager integration', () => { it( 'bulk opt out can override the balance check and close out remaining balance', async () => { - const context = await createAssetTestContext() - const assetId = await createTestAsset(context) + const context = await createAlgorandTestContext() + const { assetId } = await createTestAsset(context) const account = await createFundedAccount(context) await context.assetManager.bulkOptIn(account.address, [assetId]) @@ -211,7 +211,7 @@ describe.sequential('AssetManager integration', () => { expect(results).toHaveLength(1) await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ - code: 'NOT_OPTED_IN', + message: expect.stringContaining('is not opted into asset'), }) }, TEST_TIMEOUT, diff --git a/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts index 660c91488..619daeea3 100644 --- a/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts +++ b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createClientManager } from './fixtures' +import { createClientManager } from '../fixtures' describe.sequential('ClientManager integration', () => { it('caches network details across sequential calls', async () => { diff --git a/packages/typescript/algokit_utils/tests/clients/fixtures.ts b/packages/typescript/algokit_utils/tests/clients/fixtures.ts deleted file mode 100644 index 247d6e743..000000000 --- a/packages/typescript/algokit_utils/tests/clients/fixtures.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { AlgodClient } from '@algorandfoundation/algod-client' -import { signTransaction as signTransactionHelper, getSenderAccount, waitForConfirmation } from '../../../algokit_common/tests/helpers' -import { AssetManager } from '../../src/clients/asset-manager' -import { ClientManager } from '../../src/clients/client-manager' -import { TransactionComposer } from '../../src/transactions/composer' -import type { SignerGetter, TransactionSigner } from '../../src/transactions/common' -import type { AssetCreateParams } from '../../src/transactions/asset-config' -import type { AssetTransferParams } from '../../src/transactions/asset-transfer' -import type { PaymentParams } from '../../src/transactions/payment' -import type { Transaction, SignedTransaction } from '@algorandfoundation/algokit-transact' -import algosdk from 'algosdk' -import { randomBytes } from 'crypto' - -export type TestAccount = { - address: string - secretKey: Uint8Array -} - -type AssetTestContext = { - algodClient: AlgodClient - assetManager: AssetManager - creator: TestAccount - signers: InMemorySignerRegistry - newComposer: () => TransactionComposer -} - -class InMemorySignerRegistry implements SignerGetter { - private readonly signers = new Map() - private readonly secrets = new Map() - private defaultSigner?: TransactionSigner - private defaultSecret?: Uint8Array - - register(address: string, secretKey: Uint8Array): void { - this.secrets.set(address, secretKey) - this.signers.set(address, createTransactionSigner(secretKey)) - } - - setDefault(address: string, secretKey: Uint8Array): void { - this.defaultSecret = secretKey - this.defaultSigner = createTransactionSigner(secretKey) - this.register(address, secretKey) - } - - getSigner(address: string): TransactionSigner { - const signer = this.signers.get(address) ?? this.defaultSigner - if (!signer) { - throw new Error(`No signer registered for address ${address}`) - } - return signer - } - - getSecret(address: string): Uint8Array { - const secret = this.secrets.get(address) ?? this.defaultSecret - if (!secret) { - throw new Error(`No secret key registered for address ${address}`) - } - return secret - } -} - -export function createClientManager(): ClientManager { - const config = ClientManager.getConfigFromEnvironmentOrLocalNet() - return new ClientManager(config) -} - -export async function createAssetTestContext(): Promise { - const config = ClientManager.getConfigFromEnvironmentOrLocalNet() - const algodClient = ClientManager.getAlgodClient(config.algodConfig) - const creatorAccount = await getSenderAccount() - const creator: TestAccount = { - address: creatorAccount.address, - secretKey: creatorAccount.secretKey, - } - - const signers = new InMemorySignerRegistry() - signers.setDefault(creator.address, creator.secretKey) - - const newComposer = () => - new TransactionComposer({ - algodClient, - signerGetter: signers, - }) - - const assetManager = new AssetManager(algodClient, newComposer) - - return { - algodClient, - assetManager, - creator, - signers, - newComposer, - } -} - -export async function createTestAsset(context: AssetTestContext, overrides: Partial = {}): Promise { - const composer = context.newComposer() - const params: AssetCreateParams = { - sender: context.creator.address, - total: overrides.total ?? 1_000n, - decimals: overrides.decimals ?? 0, - unitName: overrides.unitName ?? 'TEST', - assetName: overrides.assetName ?? 'Test Asset', - defaultFrozen: overrides.defaultFrozen, - manager: overrides.manager, - reserve: overrides.reserve, - freeze: overrides.freeze, - clawback: overrides.clawback, - note: new Uint8Array(randomBytes(8)), - } - - composer.addAssetCreate({ ...params, ...overrides }) - const sendResult = await composer.send({ maxRoundsToWaitForConfirmation: 10 }) - - const confirmation = sendResult.results.at(-1)?.confirmation - if (confirmation?.assetId !== undefined && confirmation.assetId > 0) { - await waitForConfirmation(context.algodClient, sendResult.results.at(-1)!.transactionId) - return confirmation.assetId - } - - const txId = sendResult.results.at(-1)?.transactionId - if (!txId) { - throw new Error('Asset creation composer did not return a transaction id') - } - const pending = await waitForConfirmation(context.algodClient, txId) - if (pending.assetId === undefined) { - throw new Error('Pending transaction response missing assetId') - } - return pending.assetId -} - -export async function createFundedAccount(context: AssetTestContext, initialFunding: bigint = 5_000_000n): Promise { - const generated = algosdk.generateAccount() // TODO: Remove algosdk dependency - const account: TestAccount = { - address: generated.addr.toString(), - secretKey: new Uint8Array(generated.sk), - } - - context.signers.register(account.address, account.secretKey) - await sendPayment(context, { - sender: context.creator.address, - receiver: account.address, - amount: initialFunding, - }) - return account -} - -export async function sendPayment(context: AssetTestContext, params: PaymentParams) { - const composer = context.newComposer() - composer.addPayment(params) - await composer.send({ maxRoundsToWaitForConfirmation: 10 }) -} - -export async function transferAsset(context: AssetTestContext, params: AssetTransferParams) { - const composer = context.newComposer() - composer.addAssetTransfer(params) - await composer.send({ maxRoundsToWaitForConfirmation: 10 }) -} - -function createTransactionSigner(secretKey: Uint8Array): TransactionSigner { - const signSingle = async (transaction: Transaction): Promise => { - if (!transaction.sender) { - throw new Error('Transaction missing sender') - } - return signTransactionHelper(transaction, secretKey) - } - - return { - signTransaction: signSingle, - signTransactions: async (transactions: Transaction[], indices: number[]) => { - const signed: SignedTransaction[] = [] - for (const index of indices) { - const transaction = transactions[index] - if (!transaction) { - throw new Error(`Missing transaction at index ${index}`) - } - signed.push(await signSingle(transaction)) - } - return signed - }, - } -} diff --git a/packages/typescript/algokit_utils/tests/fixtures.ts b/packages/typescript/algokit_utils/tests/fixtures.ts new file mode 100644 index 000000000..72ce84345 --- /dev/null +++ b/packages/typescript/algokit_utils/tests/fixtures.ts @@ -0,0 +1,444 @@ +import { Buffer } from 'node:buffer' +import { randomBytes } from 'crypto' +import { + type Transaction, + type SignedTransaction, + TransactionType, + OnApplicationComplete, + encodeTransaction, + encodeSignedTransaction, + getTransactionId, + groupTransactions as groupTxns, +} from '@algorandfoundation/algokit-transact' +import { KmdClient } from '@algorandfoundation/kmd-client' +import * as ed from '@noble/ed25519' +import { AlgodClient } from '@algorandfoundation/algod-client' +import { IndexerClient } from '@algorandfoundation/indexer-client' +import { addressFromPublicKey, concatArrays, keyToMnemonic, mnemonicToKey, MnemonicError } from '@algorandfoundation/algokit-common' +import { AssetManager } from '../src/clients/asset-manager' +import { ClientManager } from '../src/clients/client-manager' +import { TransactionComposer } from '../src/transactions/composer' +import { waitForConfirmation, type SignerGetter, type TransactionSigner } from '../src/transactions/common' +import type { AssetCreateParams } from '../src/transactions/asset-config' +import type { AssetTransferParams } from '../src/transactions/asset-transfer' +import type { PaymentParams } from '../src/transactions/payment' + +export interface AlgodTestConfig { + algodBaseUrl: string + algodApiToken?: string + senderMnemonic?: string +} + +export function getAlgodEnv(): AlgodTestConfig { + return { + algodBaseUrl: process.env.ALGOD_BASE_URL ?? 'http://localhost:4001', + // Default token for localnet (Algorand sandbox / Algokit LocalNet) + algodApiToken: process.env.ALGOD_API_TOKEN ?? 'a'.repeat(64), + senderMnemonic: process.env.SENDER_MNEMONIC, + } +} + +// TODO: Revisit after account manager implementation +export async function getSenderMnemonic(): Promise { + if (process.env.SENDER_MNEMONIC) return process.env.SENDER_MNEMONIC + const kmdBase = process.env.KMD_BASE_URL ?? 'http://localhost:4002' + const kmdToken = process.env.KMD_API_TOKEN ?? 'a'.repeat(64) + const walletPassword = process.env.KMD_WALLET_PASSWORD ?? '' + const preferredWalletName = process.env.KMD_WALLET_NAME ?? 'unencrypted-default-wallet' + + const kmd = new KmdClient({ + baseUrl: kmdBase, + apiToken: kmdToken, + }) + + const walletsResponse = await kmd.listWallets() + const wallets = walletsResponse.wallets ?? [] + if (wallets.length === 0) { + throw new Error('No KMD wallets available') + } + + const wallet = wallets.find((w) => (w.name ?? '').toLowerCase() === preferredWalletName.toLowerCase()) ?? wallets[0] + + const walletId = wallet.id + if (!walletId) { + throw new Error('Wallet returned from KMD does not have an id') + } + + const handleResponse = await kmd.initWalletHandleToken({ + body: { + walletId, + walletPassword, + }, + }) + + const walletHandleToken = handleResponse.walletHandleToken + if (!walletHandleToken) { + throw new Error('Failed to obtain wallet handle token from KMD') + } + + try { + const keysResponse = await kmd.listKeysInWallet({ + body: { + walletHandleToken, + }, + }) + let address = keysResponse.addresses?.[0] + if (!address) { + const generated = await kmd.generateKey({ + body: { + walletHandleToken, + displayMnemonic: false, + }, + }) + address = generated.address ?? undefined + } + + if (!address) { + throw new Error('Unable to determine or generate a wallet key from KMD') + } + + const exportResponse = await kmd.exportKey({ + body: { + walletHandleToken, + walletPassword, + address, + }, + }) + + const exportedKey = exportResponse.privateKey + if (!exportedKey) { + throw new Error('KMD key export did not return a private key') + } + + const secretKey = new Uint8Array(exportedKey) + const mnemonic = keyToMnemonic(secretKey) + if (!mnemonic) { + throw new Error('Failed to convert secret key to mnemonic') + } + return mnemonic + } finally { + await kmd + .releaseWalletHandleToken({ + body: { + walletHandleToken, + }, + }) + .catch(() => undefined) + } +} + +/** + * Convenience helper: derive the sender account (address + keys) used for tests. + * Returns: + * - address: Algorand address string + * - secretKey: 64-byte Ed25519 secret key (private + public) + * - mnemonic: the 25-word mnemonic + */ +export async function getSenderAccount(): Promise<{ + address: string + secretKey: Uint8Array + mnemonic: string +}> { + const mnemonic = await getSenderMnemonic() + let secretKey: Uint8Array + try { + secretKey = mnemonicToKey(mnemonic) + } catch (error) { + if (error instanceof MnemonicError) { + throw new Error(`Failed to convert mnemonic to key: ${error.message}`) + } + throw error + } + const privateKey = secretKey.slice(0, 32) + const publicKey = secretKey.slice(32) + const address = addressFromPublicKey(publicKey) + return { address, secretKey: concatArrays(privateKey, publicKey), mnemonic } +} + +export async function signTransaction(transaction: Transaction, secretKey: Uint8Array): Promise { + const encodedTxn = encodeTransaction(transaction) + const signature = await ed.signAsync(encodedTxn, secretKey.slice(0, 32)) + + return { + transaction, + signature, + } +} + +export function groupTransactions(transactions: Transaction[]): Transaction[] { + return groupTxns(transactions) +} + +export interface IndexerTestConfig { + indexerBaseUrl: string + indexerApiToken?: string +} + +export interface CreatedAssetInfo { + assetId: bigint + txId: string +} + +export interface CreatedAppInfo { + appId: bigint + txId: string +} + +function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { + if (genesisHash instanceof Uint8Array) { + return new Uint8Array(genesisHash) + } + return new Uint8Array(Buffer.from(genesisHash, 'base64')) +} + +async function submitTransaction(transaction: Transaction, algod: AlgodClient, secretKey: Uint8Array): Promise<{ txId: string }> { + const signed = await signTransaction(transaction, secretKey) + const raw = encodeSignedTransaction(signed) + const txId = getTransactionId(transaction) + await algod.rawTransaction({ body: raw }) + await waitForConfirmation(algod, txId, 10) + return { txId } +} + +export async function createTestApp(context: AlgorandFixtureContext): Promise { + const { address, secretKey } = context.creator + const algod = context.algodClient + const sp = await algod.transactionParams() + + const approvalProgramSource = '#pragma version 8\nint 1' + const clearProgramSource = '#pragma version 8\nint 1' + + const compile = async (source: string) => { + const result = await algod.tealCompile({ body: source }) + return new Uint8Array(Buffer.from(result.result, 'base64')) + } + + const approvalProgram = await compile(approvalProgramSource) + const clearProgram = await compile(clearProgramSource) + + const firstValid = sp.lastRound + const lastValid = sp.lastRound + 1_000n + + const transaction: Transaction = { + transactionType: TransactionType.AppCall, + sender: address, + firstValid, + fee: sp.minFee, + lastValid, + genesisHash: decodeGenesisHash(sp.genesisHash), + genesisId: sp.genesisId, + appCall: { + appId: 0n, + onComplete: OnApplicationComplete.NoOp, + approvalProgram, + clearStateProgram: clearProgram, + globalStateSchema: { + numUints: 1, + numByteSlices: 1, + }, + localStateSchema: { + numUints: 0, + numByteSlices: 0, + }, + }, + } + + const { txId } = await submitTransaction(transaction, algod, secretKey) + + const appId = (await algod.pendingTransactionInformation(txId)).appId + if (!appId) { + throw new Error('Application creation transaction confirmed without returning an app id') + } + + return { appId, txId } +} + +export function getIndexerEnv(): IndexerTestConfig { + return { + indexerBaseUrl: process.env.INDEXER_BASE_URL ?? 'http://localhost:8980', + indexerApiToken: process.env.INDEXER_API_TOKEN ?? 'a'.repeat(64), + } +} + +export type TestAccount = { + address: string + secretKey: Uint8Array +} + +type AlgorandFixtureContext = { + algodClient: AlgodClient + indexerClient: IndexerClient + kmdClient: KmdClient + assetManager: AssetManager + creator: TestAccount + signers: InMemorySignerRegistry + newComposer: () => TransactionComposer +} + +class InMemorySignerRegistry implements SignerGetter { + private readonly signers = new Map() + private readonly secrets = new Map() + private defaultSigner?: TransactionSigner + private defaultSecret?: Uint8Array + + register(address: string, secretKey: Uint8Array): void { + this.secrets.set(address, secretKey) + this.signers.set(address, createTransactionSigner(secretKey)) + } + + setDefault(address: string, secretKey: Uint8Array): void { + this.defaultSecret = secretKey + this.defaultSigner = createTransactionSigner(secretKey) + this.register(address, secretKey) + } + + getSigner(address: string): TransactionSigner { + const signer = this.signers.get(address) ?? this.defaultSigner + if (!signer) { + throw new Error(`No signer registered for address ${address}`) + } + return signer + } + + getSecret(address: string): Uint8Array { + const secret = this.secrets.get(address) ?? this.defaultSecret + if (!secret) { + throw new Error(`No secret key registered for address ${address}`) + } + return secret + } +} + +export function createClientManager(): ClientManager { + const config = ClientManager.getConfigFromEnvironmentOrLocalNet() + return new ClientManager(config) +} + +export async function createAlgorandTestContext(): Promise { + const config = ClientManager.getConfigFromEnvironmentOrLocalNet() + const algodClient = ClientManager.getAlgodClient(config.algodConfig) + const indexerClient = ClientManager.getIndexerClient(config.indexerConfig) + const kmdClient = ClientManager.getKmdClient(config.kmdConfig) + const creatorAccount = await getSenderAccount() + const creator: TestAccount = { + address: creatorAccount.address, + secretKey: creatorAccount.secretKey, + } + + const signers = new InMemorySignerRegistry() + signers.setDefault(creator.address, creator.secretKey) + + const newComposer = () => + new TransactionComposer({ + algodClient, + signerGetter: signers, + }) + + const assetManager = new AssetManager(algodClient, newComposer) + + // TODO: Enhance and refine upon having all utils abstractions implemented (loosely based on rust algorand_fixture) + return { + algodClient, + indexerClient, + kmdClient, + assetManager, + creator, + signers, + newComposer, + } +} + +export async function createTestAsset( + context: AlgorandFixtureContext, + overrides: Partial = {}, +): Promise<{ assetId: bigint; txnId: string }> { + const composer = context.newComposer() + const params: AssetCreateParams = { + sender: context.creator.address, + total: overrides.total ?? 1_000n, + decimals: overrides.decimals ?? 0, + unitName: overrides.unitName ?? 'TEST', + assetName: overrides.assetName ?? 'Test Asset', + defaultFrozen: overrides.defaultFrozen, + manager: overrides.manager, + reserve: overrides.reserve, + freeze: overrides.freeze, + clawback: overrides.clawback, + note: new Uint8Array(randomBytes(8)), + } + + composer.addAssetCreate({ ...params, ...overrides }) + const sendResult = await composer.send({ maxRoundsToWaitForConfirmation: 10 }) + + const confirmation = sendResult.results.at(-1)?.confirmation + if (confirmation?.assetId !== undefined && confirmation.assetId > 0) { + const txnId = sendResult.results.at(-1)!.transactionId + await waitForConfirmation(context.algodClient, txnId, 30) + return { assetId: confirmation.assetId, txnId: txnId } + } + + const txnId = sendResult.results.at(-1)?.transactionId + if (!txnId) { + throw new Error('Asset creation composer did not return a transaction id') + } + const pending = await waitForConfirmation(context.algodClient, txnId, 30) + if (pending.assetId === undefined) { + throw new Error('Pending transaction response missing assetId') + } + return { assetId: pending.assetId, txnId: txnId } +} + +export async function createFundedAccount(context: AlgorandFixtureContext, initialFunding: bigint = 5_000_000n): Promise { + const privateKey = ed.utils.randomSecretKey() + const publicKey = await ed.getPublicKeyAsync(privateKey) + const secretKey = concatArrays(privateKey, publicKey) + const address = addressFromPublicKey(publicKey) + const account: TestAccount = { + address, + secretKey, + } + + context.signers.register(account.address, account.secretKey) + await sendPayment(context, { + sender: context.creator.address, + receiver: account.address, + amount: initialFunding, + }) + return account +} + +export async function sendPayment(context: AlgorandFixtureContext, params: PaymentParams) { + const composer = context.newComposer() + composer.addPayment(params) + await composer.send({ maxRoundsToWaitForConfirmation: 10 }) +} + +export async function transferAsset(context: AlgorandFixtureContext, params: AssetTransferParams) { + const composer = context.newComposer() + composer.addAssetTransfer(params) + await composer.send({ maxRoundsToWaitForConfirmation: 10 }) +} + +function createTransactionSigner(secretKey: Uint8Array): TransactionSigner { + const signSingle = async (transaction: Transaction): Promise => { + if (!transaction.sender) { + throw new Error('Transaction missing sender') + } + return signTransaction(transaction, secretKey) + } + + return { + signTransaction: signSingle, + signTransactions: async (transactions: Transaction[], indices: number[]) => { + const signed: SignedTransaction[] = [] + for (const index of indices) { + const transaction = transactions[index] + if (!transaction) { + throw new Error(`Missing transaction at index ${index}`) + } + signed.push(await signSingle(transaction)) + } + return signed + }, + } +} diff --git a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts index 20d1301c4..47343ef0b 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts @@ -1,18 +1,17 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyApp, getIndexerEnv } from '../../../algokit_common/tests/helpers' -import { runWhenIndexerCaughtUp } from '../../src' +import { createAlgorandTestContext, createTestApp, getIndexerEnv } from '../fixtures' +import { waitForIndexerTransaction } from '../../src' describe('Indexer search applications', () => { it('should search for applications', async () => { - const { appId, txId } = await createDummyApp() + const context = await createAlgorandTestContext() + const { appId, txId } = await createTestApp(context) const env = getIndexerEnv() const client = new IndexerClient({ baseUrl: env.indexerBaseUrl, apiToken: env.indexerApiToken ?? undefined }) - await runWhenIndexerCaughtUp(async () => { - await client.lookupTransaction(txId) - }) + await waitForIndexerTransaction(client, txId) const res = await client.searchForApplications() expect(res).toHaveProperty('applications') diff --git a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts index 8e3289940..0d5305936 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts @@ -1,17 +1,16 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyAsset, getIndexerEnv } from '../../../algokit_common/tests/helpers' -import { runWhenIndexerCaughtUp } from '../../src' +import { getIndexerEnv, createTestAsset, createAlgorandTestContext } from '../fixtures' +import { waitForIndexerTransaction } from '../../src' describe('Indexer search transactions', () => { it('should search for transactions', async () => { - const { assetId, txId } = await createDummyAsset() + const context = await createAlgorandTestContext() + const { assetId, txnId } = await createTestAsset(context) const env = getIndexerEnv() const client = new IndexerClient({ baseUrl: env.indexerBaseUrl, apiToken: env.indexerApiToken ?? undefined }) - await runWhenIndexerCaughtUp(async () => { - await client.lookupTransaction(txId) - }) + await waitForIndexerTransaction(client, txnId) const res = await client.searchForTransactions() expect(res).toHaveProperty('transactions') diff --git a/packages/typescript/package-lock.json b/packages/typescript/package-lock.json index 95fa380e4..ec8fbf477 100644 --- a/packages/typescript/package-lock.json +++ b/packages/typescript/package-lock.json @@ -31,7 +31,6 @@ "@types/node": "^20.19.17", "@typescript-eslint/eslint-plugin": "^8.44.0", "@vitest/coverage-v8": "^3.2.4", - "algosdk": "^3.5.0", "better-npm-audit": "^3.11.0", "cpy-cli": "^6.0.0", "eslint": "^9.35.0", @@ -117,9 +116,7 @@ "@algorandfoundation/algod-client": "../algod_client/dist", "@algorandfoundation/algokit-transact": "../algokit_transact/dist", "@algorandfoundation/indexer-client": "../indexer_client/dist", - "@algorandfoundation/kmd-client": "../kmd_client/dist", - "@noble/ed25519": "^3.0.0", - "algosdk": "^3.5.0" + "@algorandfoundation/kmd-client": "../kmd_client/dist" }, "engines": { "node": ">=20.0" @@ -2351,33 +2348,6 @@ "node": ">= 14" } }, - "node_modules/algosdk": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/algosdk/-/algosdk-3.5.0.tgz", - "integrity": "sha512-9Q6lKAbl5Zz0VKjAMC7OYpnpDqmK/qa5LOALKxcF/jBs5k309lMezFC3ka0dSXPq64IKsUzhShSdOZrwYfr7tA==", - "dev": true, - "license": "MIT", - "dependencies": { - "algorand-msgpack": "^1.1.0", - "hi-base32": "^0.5.1", - "js-sha256": "^0.9.0", - "js-sha3": "^0.8.0", - "js-sha512": "^0.8.0", - "json-bigint": "^1.0.0", - "tweetnacl": "^1.0.3", - "vlq": "^2.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/algosdk/node_modules/js-sha512": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz", - "integrity": "sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==", - "dev": true, - "license": "MIT" - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -3638,20 +3608,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/js-sha512": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.9.0.tgz", @@ -5144,13 +5100,6 @@ "license": "0BSD", "optional": true }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "dev": true, - "license": "Unlicense" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5460,13 +5409,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vlq": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vlq/-/vlq-2.0.4.tgz", - "integrity": "sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA==", - "dev": true, - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 40eed2545..685829d9c 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -40,7 +40,6 @@ "@types/node": "^20.19.17", "@typescript-eslint/eslint-plugin": "^8.44.0", "@vitest/coverage-v8": "^3.2.4", - "algosdk": "^3.5.0", "better-npm-audit": "^3.11.0", "cpy-cli": "^6.0.0", "eslint": "^9.35.0", From ca68e91068aef21c8e4c73fdfb29874c4652b3d0 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 17 Oct 2025 13:44:52 +0200 Subject: [PATCH 17/18] chore: adding retry to default fetch client; minor clippy tweaks --- .../base/src/core/client-config.ts.j2 | 9 +- .../base/src/core/fetch-http-request.ts.j2 | 114 ++++++++- crates/algokit_http_client/src/lib.rs | 11 +- .../src/clients/asset_manager.rs | 95 +++++--- .../tests/clients/asset_manager.rs | 4 +- .../algod_client/src/core/client-config.ts | 8 + .../src/core/fetch-http-request.ts | 114 ++++++++- .../algokit_utils/src/clients/app-manager.ts | 5 +- .../src/clients/client-manager.ts | 11 +- .../src/clients/http/retry-http-request.ts | 227 ------------------ .../typescript/algokit_utils/src/index.ts | 1 - .../algokit_utils/src/transactions/common.ts | 4 + .../algokit_utils/tests/fixtures.ts | 3 +- .../indexer_client/src/core/client-config.ts | 8 + .../src/core/fetch-http-request.ts | 114 ++++++++- .../kmd_client/src/core/client-config.ts | 8 + .../kmd_client/src/core/fetch-http-request.ts | 114 ++++++++- 17 files changed, 569 insertions(+), 281 deletions(-) delete mode 100644 packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts diff --git a/api/oas_generator/ts_oas_generator/templates/base/src/core/client-config.ts.j2 b/api/oas_generator/ts_oas_generator/templates/base/src/core/client-config.ts.j2 index e54ab5480..b02340b73 100644 --- a/api/oas_generator/ts_oas_generator/templates/base/src/core/client-config.ts.j2 +++ b/api/oas_generator/ts_oas_generator/templates/base/src/core/client-config.ts.j2 @@ -11,5 +11,12 @@ export interface ClientConfig { password?: string; headers?: Record | (() => Record | Promise>); encodePath?: (path: string) => string; + /** Optional override for retry attempts; values <= 1 disable retries. This is the canonical field. */ + maxRetries?: number; + /** Optional cap on exponential backoff delay in milliseconds. */ + maxBackoffMs?: number; + /** Optional list of HTTP status codes that should trigger a retry. */ + retryStatusCodes?: number[]; + /** Optional list of Node.js/System error codes that should trigger a retry. */ + retryErrorCodes?: string[]; } - diff --git a/api/oas_generator/ts_oas_generator/templates/base/src/core/fetch-http-request.ts.j2 b/api/oas_generator/ts_oas_generator/templates/base/src/core/fetch-http-request.ts.j2 index 8f02dcbc8..c7651b8b2 100644 --- a/api/oas_generator/ts_oas_generator/templates/base/src/core/fetch-http-request.ts.j2 +++ b/api/oas_generator/ts_oas_generator/templates/base/src/core/fetch-http-request.ts.j2 @@ -1,8 +1,120 @@ import { BaseHttpRequest, type ApiRequestOptions } from './base-http-request'; import { request } from './request'; +const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504]; +const RETRY_ERROR_CODES = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN', + 'EPROTO', +]; + +const DEFAULT_MAX_TRIES = 5; +const DEFAULT_MAX_BACKOFF_MS = 10_000; + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; + } + return undefined; +}; + +const extractStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined; + } + const candidate = error as { status?: unknown; response?: { status?: unknown } }; + return toNumber(candidate.status ?? candidate.response?.status); +}; + +const extractCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined; + } + const candidate = error as { code?: unknown; cause?: { code?: unknown } }; + const raw = candidate.code ?? candidate.cause?.code; + return typeof raw === 'string' ? raw : undefined; +}; + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const normalizeTries = (maxRetries?: number): number => { + const candidate = maxRetries; + if (typeof candidate !== 'number' || !Number.isFinite(candidate)) { + return DEFAULT_MAX_TRIES; + } + const rounded = Math.floor(candidate); + return rounded <= 1 ? 1 : rounded; +}; + +const normalizeBackoff = (maxBackoffMs?: number): number => { + if (typeof maxBackoffMs !== 'number' || !Number.isFinite(maxBackoffMs)) { + return DEFAULT_MAX_BACKOFF_MS; + } + const normalized = Math.floor(maxBackoffMs); + return normalized <= 0 ? 0 : normalized; +}; + export class FetchHttpRequest extends BaseHttpRequest { async request(options: ApiRequestOptions): Promise { - return request(this.config, options); + const maxTries = normalizeTries(this.config.maxRetries); + const maxBackoffMs = normalizeBackoff(this.config.maxBackoffMs); + + let attempt = 1; + let lastError: unknown; + while (attempt <= maxTries) { + try { + return await request(this.config, options); + } catch (error) { + lastError = error; + if (!this.shouldRetry(error, attempt, maxTries)) { + throw error; + } + + const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), maxBackoffMs); + if (backoff > 0) { + await delay(backoff); + } + attempt += 1; + } + } + + throw lastError ?? new Error(`Request failed after ${maxTries} attempt(s)`) + } + + private shouldRetry(error: unknown, attempt: number, maxTries: number): boolean { + if (attempt >= maxTries) { + return false; + } + + const status = extractStatus(error); + if (status !== undefined) { + const retryStatuses = this.config.retryStatusCodes ?? RETRY_STATUS_CODES; + if (retryStatuses.includes(status)) { + return true; + } + } + + const code = extractCode(error); + if (code) { + const retryCodes = this.config.retryErrorCodes ?? RETRY_ERROR_CODES; + if (retryCodes.includes(code)) { + return true; + } + } + + return false; } } diff --git a/crates/algokit_http_client/src/lib.rs b/crates/algokit_http_client/src/lib.rs index c95cc52b8..99b70545c 100644 --- a/crates/algokit_http_client/src/lib.rs +++ b/crates/algokit_http_client/src/lib.rs @@ -10,6 +10,9 @@ uniffi::setup_scaffolding!(); pub enum HttpError { #[snafu(display("HttpError: {message}"))] RequestError { message: String }, + // Keep legacy style to preserve downstream string-matching tests + #[snafu(display("Request failed with status {status}: {message}"))] + StatusError { status: u16, message: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -151,12 +154,16 @@ impl HttpClient for DefaultHttpClient { if !response.status().is_success() { let status = response.status(); + let status_code = status.as_u16(); let text = response .text() .await .unwrap_or_else(|_| "Failed to read error response text".to_string()); - return Err(HttpError::RequestError { - message: format!("Request failed with status {}: {}", status, text), + // Include status display string (e.g., "400 Bad Request") in message to match legacy expectations + let message = format!("{}: {}", status, text); + return Err(HttpError::StatusError { + status: status_code, + message, }); } diff --git a/crates/algokit_utils/src/clients/asset_manager.rs b/crates/algokit_utils/src/clients/asset_manager.rs index 12df5a950..e6a9e2c88 100644 --- a/crates/algokit_utils/src/clients/asset_manager.rs +++ b/crates/algokit_utils/src/clients/asset_manager.rs @@ -185,7 +185,7 @@ impl AssetManager { .await .map_err(|error| { map_get_asset_by_id_error(&error, asset_id) - .unwrap_or_else(|| AssetManagerError::AlgodClientError { source: error }) + .unwrap_or(AssetManagerError::AlgodClientError { source: error }) })?; Ok(asset.into()) @@ -205,7 +205,7 @@ impl AssetManager { .await .map_err(|error| { map_account_asset_information_error(&error, sender_str.as_str(), asset_id) - .unwrap_or_else(|| AssetManagerError::AlgodClientError { source: error }) + .unwrap_or(AssetManagerError::AlgodClientError { source: error }) }) } @@ -354,22 +354,28 @@ impl AssetManager { fn map_get_asset_by_id_error(error: &AlgodError, asset_id: u64) -> Option { match error { - AlgodError::Api { source } => match source { - AlgodApiError::GetAssetById { error } => match error { - GetAssetByIdError::Status404(_) => { + AlgodError::Api { + source: + AlgodApiError::GetAssetById { + error: GetAssetByIdError::Status404(_), + }, + } => Some(AssetManagerError::AssetNotFound { asset_id }), + AlgodError::Api { .. } => None, + AlgodError::Http { source } => { + // Prefer structured status when available, fallback to message matching for older clients + match source { + HttpError::StatusError { status, .. } if *status == 404 => { Some(AssetManagerError::AssetNotFound { asset_id }) } - _ => None, - }, - _ => None, - }, - AlgodError::Http { source } => http_error_message(source).and_then(|message| { - if message.contains("status 404") { - Some(AssetManagerError::AssetNotFound { asset_id }) - } else { - None + _ => http_error_message(source).and_then(|message| { + if message.contains("status 404") { + Some(AssetManagerError::AssetNotFound { asset_id }) + } else { + None + } + }), } - }), + } _ => None, } } @@ -380,33 +386,47 @@ fn map_account_asset_information_error( asset_id: u64, ) -> Option { match error { - AlgodError::Api { source } => match source { - AlgodApiError::AccountAssetInformation { error } => match error { - AccountAssetInformationError::Status400(_) => { + AlgodError::Api { + source: + AlgodApiError::AccountAssetInformation { + error: AccountAssetInformationError::Status400(_), + }, + } => Some(AssetManagerError::AccountNotFound { + address: address.to_string(), + }), + AlgodError::Api { .. } => None, + AlgodError::Http { source } => { + // Prefer structured status when available, fallback to message matching for older clients + match source { + HttpError::StatusError { status, .. } if *status == 404 => { + Some(AssetManagerError::NotOptedIn { + address: address.to_string(), + asset_id, + }) + } + HttpError::StatusError { status, .. } if *status == 400 => { Some(AssetManagerError::AccountNotFound { address: address.to_string(), }) } - _ => None, - }, - _ => None, - }, - AlgodError::Http { source } => http_error_message(source).and_then(|message| { - if message.contains("status 404") { - Some(AssetManagerError::NotOptedIn { - address: address.to_string(), - asset_id, - }) - } else if message.contains("status 400") - || message.to_ascii_lowercase().contains("account not found") - { - Some(AssetManagerError::AccountNotFound { - address: address.to_string(), - }) - } else { - None + _ => http_error_message(source).and_then(|message| { + if message.contains("status 404") { + Some(AssetManagerError::NotOptedIn { + address: address.to_string(), + asset_id, + }) + } else if message.contains("status 400") + || message.to_ascii_lowercase().contains("account not found") + { + Some(AssetManagerError::AccountNotFound { + address: address.to_string(), + }) + } else { + None + } + }), } - }), + } _ => None, } } @@ -414,6 +434,7 @@ fn map_account_asset_information_error( fn http_error_message(error: &HttpError) -> Option<&str> { match error { HttpError::RequestError { message } => Some(message.as_str()), + HttpError::StatusError { message, .. } => Some(message.as_str()), } } diff --git a/crates/algokit_utils/tests/clients/asset_manager.rs b/crates/algokit_utils/tests/clients/asset_manager.rs index c760dbea3..2cd3f8e91 100644 --- a/crates/algokit_utils/tests/clients/asset_manager.rs +++ b/crates/algokit_utils/tests/clients/asset_manager.rs @@ -44,7 +44,9 @@ async fn test_get_asset_by_id_nonexistent( .expect_err("expected asset lookup to fail"); assert!(matches!( error, - AssetManagerError::AssetNotFound { asset_id: 999_999_999 } + AssetManagerError::AssetNotFound { + asset_id: 999_999_999 + } )); Ok(()) diff --git a/packages/typescript/algod_client/src/core/client-config.ts b/packages/typescript/algod_client/src/core/client-config.ts index 9f3a1a5de..fb2466a3a 100644 --- a/packages/typescript/algod_client/src/core/client-config.ts +++ b/packages/typescript/algod_client/src/core/client-config.ts @@ -11,4 +11,12 @@ export interface ClientConfig { password?: string headers?: Record | (() => Record | Promise>) encodePath?: (path: string) => string + /** Optional override for retry attempts; values <= 1 disable retries. This is the canonical field. */ + maxRetries?: number + /** Optional cap on exponential backoff delay in milliseconds. */ + maxBackoffMs?: number + /** Optional list of HTTP status codes that should trigger a retry. */ + retryStatusCodes?: number[] + /** Optional list of Node.js/System error codes that should trigger a retry. */ + retryErrorCodes?: string[] } diff --git a/packages/typescript/algod_client/src/core/fetch-http-request.ts b/packages/typescript/algod_client/src/core/fetch-http-request.ts index d57c1e667..9286bd076 100644 --- a/packages/typescript/algod_client/src/core/fetch-http-request.ts +++ b/packages/typescript/algod_client/src/core/fetch-http-request.ts @@ -1,8 +1,120 @@ import { BaseHttpRequest, type ApiRequestOptions } from './base-http-request' import { request } from './request' +const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] +const RETRY_ERROR_CODES = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN', + 'EPROTO', +] + +const DEFAULT_MAX_TRIES = 5 +const DEFAULT_MAX_BACKOFF_MS = 10_000 + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value + } + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +const extractStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { status?: unknown; response?: { status?: unknown } } + return toNumber(candidate.status ?? candidate.response?.status) +} + +const extractCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { code?: unknown; cause?: { code?: unknown } } + const raw = candidate.code ?? candidate.cause?.code + return typeof raw === 'string' ? raw : undefined +} + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const normalizeTries = (maxRetries?: number): number => { + const candidate = maxRetries + if (typeof candidate !== 'number' || !Number.isFinite(candidate)) { + return DEFAULT_MAX_TRIES + } + const rounded = Math.floor(candidate) + return rounded <= 1 ? 1 : rounded +} + +const normalizeBackoff = (maxBackoffMs?: number): number => { + if (typeof maxBackoffMs !== 'number' || !Number.isFinite(maxBackoffMs)) { + return DEFAULT_MAX_BACKOFF_MS + } + const normalized = Math.floor(maxBackoffMs) + return normalized <= 0 ? 0 : normalized +} + export class FetchHttpRequest extends BaseHttpRequest { async request(options: ApiRequestOptions): Promise { - return request(this.config, options) + const maxTries = normalizeTries(this.config.maxRetries) + const maxBackoffMs = normalizeBackoff(this.config.maxBackoffMs) + + let attempt = 1 + let lastError: unknown + while (attempt <= maxTries) { + try { + return await request(this.config, options) + } catch (error) { + lastError = error + if (!this.shouldRetry(error, attempt, maxTries)) { + throw error + } + + const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), maxBackoffMs) + if (backoff > 0) { + await delay(backoff) + } + attempt += 1 + } + } + + throw lastError ?? new Error(`Request failed after ${maxTries} attempt(s)`) + } + + private shouldRetry(error: unknown, attempt: number, maxTries: number): boolean { + if (attempt >= maxTries) { + return false + } + + const status = extractStatus(error) + if (status !== undefined) { + const retryStatuses = this.config.retryStatusCodes ?? RETRY_STATUS_CODES + if (retryStatuses.includes(status)) { + return true + } + } + + const code = extractCode(error) + if (code) { + const retryCodes = this.config.retryErrorCodes ?? RETRY_ERROR_CODES + if (retryCodes.includes(code)) { + return true + } + } + + return false } } diff --git a/packages/typescript/algokit_utils/src/clients/app-manager.ts b/packages/typescript/algokit_utils/src/clients/app-manager.ts index 785a3cdf7..ea9078b9d 100644 --- a/packages/typescript/algokit_utils/src/clients/app-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/app-manager.ts @@ -205,7 +205,6 @@ export class AppManager { for (const stateVal of state) { const keyRaw = toBytes(stateVal.key) const keyBase64 = stateVal.key - const keyString = keyBase64 // TODO: we will need to update the algod client to return int here if (stateVal.value.type === 1n) { @@ -225,14 +224,14 @@ export class AppManager { valueBase64, value: valueStr, } - stateValues[keyString] = bytesState + stateValues[keyBase64] = bytesState } else if (stateVal.value.type === 2n) { const uintState: UintAppState = { keyRaw, keyBase64, value: BigInt(stateVal.value.uint), } - stateValues[keyString] = uintState + stateValues[keyBase64] = uintState } else { throw new Error(`Unknown state data type: ${stateVal.value.type}`) } diff --git a/packages/typescript/algokit_utils/src/clients/client-manager.ts b/packages/typescript/algokit_utils/src/clients/client-manager.ts index 20e4892dc..d03b72e9c 100644 --- a/packages/typescript/algokit_utils/src/clients/client-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/client-manager.ts @@ -1,4 +1,10 @@ -import { AlgodClient, ApiError, type BaseHttpRequest, type ClientConfig as HttpClientConfig } from '@algorandfoundation/algod-client' +import { + AlgodClient, + ApiError, + FetchHttpRequest, + type BaseHttpRequest, + type ClientConfig as HttpClientConfig, +} from '@algorandfoundation/algod-client' import { IndexerClient } from '@algorandfoundation/indexer-client' import { KmdClient } from '@algorandfoundation/kmd-client' import { Buffer } from 'buffer' @@ -13,7 +19,6 @@ import { genesisIdIsMainnet, genesisIdIsTestnet, } from './network-client' -import { RetryHttpRequest } from './http/retry-http-request' export interface ClientManagerClients { algod: AlgodClient @@ -412,7 +417,7 @@ export class ClientManager { const clientConfig = this.buildHttpClientConfig(config, defaultHeaderName) return { clientConfig, - request: new RetryHttpRequest(clientConfig), + request: new FetchHttpRequest(clientConfig), } } diff --git a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts b/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts deleted file mode 100644 index 7c50663d2..000000000 --- a/packages/typescript/algokit_utils/src/clients/http/retry-http-request.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { - ApiError, - type ApiRequestOptions, - BaseHttpRequest, - decodeMsgPack, - encodeMsgPack, - type ClientConfig, -} from '@algorandfoundation/algod-client' - -const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] -const RETRY_ERROR_CODES = [ - 'ETIMEDOUT', - 'ECONNRESET', - 'EADDRINUSE', - 'ECONNREFUSED', - 'EPIPE', - 'ENOTFOUND', - 'ENETUNREACH', - 'EAI_AGAIN', - 'EPROTO', -] - -const DEFAULT_MAX_TRIES = 5 -const DEFAULT_MAX_BACKOFF_MS = 10_000 - -const encodeURIPath = (path: string): string => encodeURI(path).replace(/%5B/g, '[').replace(/%5D/g, ']') - -const toNumber = (value: unknown): number | undefined => { - if (typeof value === 'number') { - return Number.isNaN(value) ? undefined : value - } - if (typeof value === 'string') { - const parsed = Number(value) - return Number.isNaN(parsed) ? undefined : parsed - } - return undefined -} - -const extractStatus = (error: unknown): number | undefined => { - if (!error || typeof error !== 'object') { - return undefined - } - const candidate = error as { status?: unknown; response?: { status?: unknown } } - return toNumber(candidate.status ?? candidate.response?.status) -} - -const extractCode = (error: unknown): string | undefined => { - if (!error || typeof error !== 'object') { - return undefined - } - const candidate = error as { code?: unknown; cause?: { code?: unknown } } - const raw = candidate.code ?? candidate.cause?.code - return typeof raw === 'string' ? raw : undefined -} - -const delay = async (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms) - }) - -export interface RetryHttpRequestOptions { - maxTries?: number - maxBackoffMs?: number -} - -export class RetryHttpRequest extends BaseHttpRequest { - private readonly maxTries: number - private readonly maxBackoffMs: number - - constructor(config: ClientConfig, options?: RetryHttpRequestOptions) { - super(config) - this.maxTries = options?.maxTries ?? DEFAULT_MAX_TRIES - this.maxBackoffMs = options?.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS - } - - async request(options: ApiRequestOptions): Promise { - let attempt = 1 - let lastError: unknown - while (attempt <= this.maxTries) { - try { - return await this.execute(options) - } catch (error) { - lastError = error - if (!this.shouldRetry(error, attempt)) { - throw error - } - - const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), this.maxBackoffMs) - if (backoff > 0) { - // eslint-disable-next-line no-console - console.warn(`Retrying request after ${backoff}ms due to error:`, error) - await delay(backoff) - } - attempt += 1 - } - } - - throw lastError ?? new Error('Request failed after exhausting retries') - } - - private shouldRetry(error: unknown, attempt: number): boolean { - if (attempt >= this.maxTries) { - return false - } - - const status = extractStatus(error) - if (status !== undefined && RETRY_STATUS_CODES.includes(status)) { - return true - } - - const code = extractCode(error) - if (code && RETRY_ERROR_CODES.includes(code)) { - return true - } - - return false - } - - private async execute(options: ApiRequestOptions): Promise { - let rawPath = options.url - if (options.path) { - for (const [key, value] of Object.entries(options.path)) { - const serialized = typeof value === 'bigint' ? value.toString() : String(value) - const encoded = this.config.encodePath ? this.config.encodePath(serialized) : encodeURIPath(serialized) - rawPath = rawPath.replace(`{${key}}`, encoded) - } - } - - const url = new URL(rawPath, this.config.baseUrl) - - if (options.query) { - for (const [key, value] of Object.entries(options.query)) { - if (value === undefined || value === null) continue - if (Array.isArray(value)) { - for (const item of value) { - url.searchParams.append(key, item.toString()) - } - } else { - url.searchParams.append(key, value.toString()) - } - } - } - - const headers: Record = { - ...(typeof this.config.headers === 'function' ? await this.config.headers() : (this.config.headers ?? {})), - ...(options.headers ?? {}), - } - - if (this.config.apiToken) { - headers['X-Algo-API-Token'] = this.config.apiToken - } - - const token = typeof this.config.token === 'function' ? await this.config.token() : this.config.token - if (token) { - headers['Authorization'] = `Bearer ${token}` - } else if (this.config.username && this.config.password) { - headers['Authorization'] = `Basic ${btoa(`${this.config.username}:${this.config.password}`)}` - } - - type FetchRequestInit = Parameters[1] - type FetchBody = FetchRequestInit extends { body?: infer B } ? B : undefined - let payload: FetchBody - if (options.body != null) { - const { body } = options - if (body instanceof Uint8Array) { - payload = body - } else if (typeof body === 'string') { - payload = body - } else if (options.mediaType?.includes('msgpack')) { - payload = encodeMsgPack(body) - } else if (options.mediaType?.includes('json')) { - payload = JSON.stringify(body) - } else { - payload = JSON.stringify(body) - } - } - - const response = await fetch(url.toString(), { - method: options.method, - headers, - body: payload, - credentials: this.config.credentials, - }) - - if (!response.ok) { - let errorBody: unknown - try { - const contentType = response.headers.get('content-type') ?? '' - if (contentType.includes('application/msgpack')) { - errorBody = decodeMsgPack(new Uint8Array(await response.arrayBuffer())) - } else if (contentType.includes('application/json')) { - errorBody = JSON.parse(await response.text()) - } else { - errorBody = await response.text() - } - } catch { - errorBody = undefined - } - throw new ApiError(url.toString(), response.status, errorBody) - } - - if (options.responseHeader) { - const value = response.headers.get(options.responseHeader) - return value as unknown as T - } - - const contentType = response.headers.get('content-type') ?? '' - - if (contentType.includes('application/msgpack')) { - return new Uint8Array(await response.arrayBuffer()) as unknown as T - } - - if (contentType.includes('application/octet-stream') || contentType.includes('application/x-binary')) { - return new Uint8Array(await response.arrayBuffer()) as unknown as T - } - - if (contentType.includes('application/json')) { - return (await response.text()) as unknown as T - } - - if (!contentType) { - return new Uint8Array(await response.arrayBuffer()) as unknown as T - } - - return (await response.text()) as unknown as T - } -} diff --git a/packages/typescript/algokit_utils/src/index.ts b/packages/typescript/algokit_utils/src/index.ts index 9cd4cf408..45e0271d9 100644 --- a/packages/typescript/algokit_utils/src/index.ts +++ b/packages/typescript/algokit_utils/src/index.ts @@ -3,7 +3,6 @@ export * from './clients/asset-manager' export * from './clients/client-manager' export * from './clients/app-manager' export * from './clients/network-client' -export * from './clients/http/retry-http-request' export * from './testing/indexer' export * from '@algorandfoundation/algokit-transact' diff --git a/packages/typescript/algokit_utils/src/transactions/common.ts b/packages/typescript/algokit_utils/src/transactions/common.ts index 90f70e748..409bd9c23 100644 --- a/packages/typescript/algokit_utils/src/transactions/common.ts +++ b/packages/typescript/algokit_utils/src/transactions/common.ts @@ -248,8 +248,12 @@ export async function waitForConfirmation( } } catch (e: unknown) { if (e instanceof ApiError && e.status === 404) { + // Transaction not yet in pool, wait for next block + await algodClient.waitForBlock(currentRound) currentRound++ continue + } else { + throw e } } diff --git a/packages/typescript/algokit_utils/tests/fixtures.ts b/packages/typescript/algokit_utils/tests/fixtures.ts index 72ce84345..73a2abe25 100644 --- a/packages/typescript/algokit_utils/tests/fixtures.ts +++ b/packages/typescript/algokit_utils/tests/fixtures.ts @@ -149,10 +149,9 @@ export async function getSenderAccount(): Promise<{ } throw error } - const privateKey = secretKey.slice(0, 32) const publicKey = secretKey.slice(32) const address = addressFromPublicKey(publicKey) - return { address, secretKey: concatArrays(privateKey, publicKey), mnemonic } + return { address, secretKey, mnemonic } } export async function signTransaction(transaction: Transaction, secretKey: Uint8Array): Promise { diff --git a/packages/typescript/indexer_client/src/core/client-config.ts b/packages/typescript/indexer_client/src/core/client-config.ts index 9f3a1a5de..fb2466a3a 100644 --- a/packages/typescript/indexer_client/src/core/client-config.ts +++ b/packages/typescript/indexer_client/src/core/client-config.ts @@ -11,4 +11,12 @@ export interface ClientConfig { password?: string headers?: Record | (() => Record | Promise>) encodePath?: (path: string) => string + /** Optional override for retry attempts; values <= 1 disable retries. This is the canonical field. */ + maxRetries?: number + /** Optional cap on exponential backoff delay in milliseconds. */ + maxBackoffMs?: number + /** Optional list of HTTP status codes that should trigger a retry. */ + retryStatusCodes?: number[] + /** Optional list of Node.js/System error codes that should trigger a retry. */ + retryErrorCodes?: string[] } diff --git a/packages/typescript/indexer_client/src/core/fetch-http-request.ts b/packages/typescript/indexer_client/src/core/fetch-http-request.ts index d57c1e667..9286bd076 100644 --- a/packages/typescript/indexer_client/src/core/fetch-http-request.ts +++ b/packages/typescript/indexer_client/src/core/fetch-http-request.ts @@ -1,8 +1,120 @@ import { BaseHttpRequest, type ApiRequestOptions } from './base-http-request' import { request } from './request' +const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] +const RETRY_ERROR_CODES = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN', + 'EPROTO', +] + +const DEFAULT_MAX_TRIES = 5 +const DEFAULT_MAX_BACKOFF_MS = 10_000 + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value + } + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +const extractStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { status?: unknown; response?: { status?: unknown } } + return toNumber(candidate.status ?? candidate.response?.status) +} + +const extractCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { code?: unknown; cause?: { code?: unknown } } + const raw = candidate.code ?? candidate.cause?.code + return typeof raw === 'string' ? raw : undefined +} + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const normalizeTries = (maxRetries?: number): number => { + const candidate = maxRetries + if (typeof candidate !== 'number' || !Number.isFinite(candidate)) { + return DEFAULT_MAX_TRIES + } + const rounded = Math.floor(candidate) + return rounded <= 1 ? 1 : rounded +} + +const normalizeBackoff = (maxBackoffMs?: number): number => { + if (typeof maxBackoffMs !== 'number' || !Number.isFinite(maxBackoffMs)) { + return DEFAULT_MAX_BACKOFF_MS + } + const normalized = Math.floor(maxBackoffMs) + return normalized <= 0 ? 0 : normalized +} + export class FetchHttpRequest extends BaseHttpRequest { async request(options: ApiRequestOptions): Promise { - return request(this.config, options) + const maxTries = normalizeTries(this.config.maxRetries) + const maxBackoffMs = normalizeBackoff(this.config.maxBackoffMs) + + let attempt = 1 + let lastError: unknown + while (attempt <= maxTries) { + try { + return await request(this.config, options) + } catch (error) { + lastError = error + if (!this.shouldRetry(error, attempt, maxTries)) { + throw error + } + + const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), maxBackoffMs) + if (backoff > 0) { + await delay(backoff) + } + attempt += 1 + } + } + + throw lastError ?? new Error(`Request failed after ${maxTries} attempt(s)`) + } + + private shouldRetry(error: unknown, attempt: number, maxTries: number): boolean { + if (attempt >= maxTries) { + return false + } + + const status = extractStatus(error) + if (status !== undefined) { + const retryStatuses = this.config.retryStatusCodes ?? RETRY_STATUS_CODES + if (retryStatuses.includes(status)) { + return true + } + } + + const code = extractCode(error) + if (code) { + const retryCodes = this.config.retryErrorCodes ?? RETRY_ERROR_CODES + if (retryCodes.includes(code)) { + return true + } + } + + return false } } diff --git a/packages/typescript/kmd_client/src/core/client-config.ts b/packages/typescript/kmd_client/src/core/client-config.ts index 9f3a1a5de..fb2466a3a 100644 --- a/packages/typescript/kmd_client/src/core/client-config.ts +++ b/packages/typescript/kmd_client/src/core/client-config.ts @@ -11,4 +11,12 @@ export interface ClientConfig { password?: string headers?: Record | (() => Record | Promise>) encodePath?: (path: string) => string + /** Optional override for retry attempts; values <= 1 disable retries. This is the canonical field. */ + maxRetries?: number + /** Optional cap on exponential backoff delay in milliseconds. */ + maxBackoffMs?: number + /** Optional list of HTTP status codes that should trigger a retry. */ + retryStatusCodes?: number[] + /** Optional list of Node.js/System error codes that should trigger a retry. */ + retryErrorCodes?: string[] } diff --git a/packages/typescript/kmd_client/src/core/fetch-http-request.ts b/packages/typescript/kmd_client/src/core/fetch-http-request.ts index d57c1e667..9286bd076 100644 --- a/packages/typescript/kmd_client/src/core/fetch-http-request.ts +++ b/packages/typescript/kmd_client/src/core/fetch-http-request.ts @@ -1,8 +1,120 @@ import { BaseHttpRequest, type ApiRequestOptions } from './base-http-request' import { request } from './request' +const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] +const RETRY_ERROR_CODES = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN', + 'EPROTO', +] + +const DEFAULT_MAX_TRIES = 5 +const DEFAULT_MAX_BACKOFF_MS = 10_000 + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value + } + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +const extractStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { status?: unknown; response?: { status?: unknown } } + return toNumber(candidate.status ?? candidate.response?.status) +} + +const extractCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined + } + const candidate = error as { code?: unknown; cause?: { code?: unknown } } + const raw = candidate.code ?? candidate.cause?.code + return typeof raw === 'string' ? raw : undefined +} + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const normalizeTries = (maxRetries?: number): number => { + const candidate = maxRetries + if (typeof candidate !== 'number' || !Number.isFinite(candidate)) { + return DEFAULT_MAX_TRIES + } + const rounded = Math.floor(candidate) + return rounded <= 1 ? 1 : rounded +} + +const normalizeBackoff = (maxBackoffMs?: number): number => { + if (typeof maxBackoffMs !== 'number' || !Number.isFinite(maxBackoffMs)) { + return DEFAULT_MAX_BACKOFF_MS + } + const normalized = Math.floor(maxBackoffMs) + return normalized <= 0 ? 0 : normalized +} + export class FetchHttpRequest extends BaseHttpRequest { async request(options: ApiRequestOptions): Promise { - return request(this.config, options) + const maxTries = normalizeTries(this.config.maxRetries) + const maxBackoffMs = normalizeBackoff(this.config.maxBackoffMs) + + let attempt = 1 + let lastError: unknown + while (attempt <= maxTries) { + try { + return await request(this.config, options) + } catch (error) { + lastError = error + if (!this.shouldRetry(error, attempt, maxTries)) { + throw error + } + + const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), maxBackoffMs) + if (backoff > 0) { + await delay(backoff) + } + attempt += 1 + } + } + + throw lastError ?? new Error(`Request failed after ${maxTries} attempt(s)`) + } + + private shouldRetry(error: unknown, attempt: number, maxTries: number): boolean { + if (attempt >= maxTries) { + return false + } + + const status = extractStatus(error) + if (status !== undefined) { + const retryStatuses = this.config.retryStatusCodes ?? RETRY_STATUS_CODES + if (retryStatuses.includes(status)) { + return true + } + } + + const code = extractCode(error) + if (code) { + const retryCodes = this.config.retryErrorCodes ?? RETRY_ERROR_CODES + if (retryCodes.includes(code)) { + return true + } + } + + return false } } From 0425f83ebf44e0e7be59d4dd387872a22a5c18b0 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 24 Oct 2025 11:51:36 +0200 Subject: [PATCH 18/18] fix: limit account information endpoint to json only --- .../rust_oas_generator/parser/oas_parser.py | 4 +- .../generator/template_engine.py | 6 +- api/scripts/convert-openapi.ts | 89 ++++++++++-------- api/specs/algod.oas3.json | 90 +++++-------------- .../src/apis/account_asset_information.rs | 21 +---- .../src/apis/account_information.rs | 18 +--- crates/algod_client/src/apis/client.rs | 4 - .../src/models/account_asset_information.rs | 21 ----- .../src/clients/asset_manager.rs | 2 +- .../tests/common/local_net_dispenser.rs | 2 +- .../transactions/composer/asset_freeze.rs | 4 +- .../transactions/composer/key_registration.rs | 8 +- .../algod_client/src/apis/api.service.ts | 15 ++-- 13 files changed, 97 insertions(+), 187 deletions(-) diff --git a/api/oas_generator/rust_oas_generator/parser/oas_parser.py b/api/oas_generator/rust_oas_generator/parser/oas_parser.py index 506331dc6..c1ce04f21 100644 --- a/api/oas_generator/rust_oas_generator/parser/oas_parser.py +++ b/api/oas_generator/rust_oas_generator/parser/oas_parser.py @@ -787,12 +787,12 @@ def _parse_parameter(self, param_data: dict[str, Any]) -> Parameter | None: if not name: return None - # Skip `format` query parameter when constrained to msgpack only + # Skip `format` query parameter when constrained to a single format (json or msgpack) in_location = param_data.get("in", "query") if name == "format" and in_location == "query": schema_obj = param_data.get("schema", {}) or {} enum_vals = schema_obj.get("enum") - if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] == "msgpack": + if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] in ("msgpack", "json"): return None schema = param_data.get("schema", {}) diff --git a/api/oas_generator/ts_oas_generator/generator/template_engine.py b/api/oas_generator/ts_oas_generator/generator/template_engine.py index 02c0bb62c..fe5e0e679 100644 --- a/api/oas_generator/ts_oas_generator/generator/template_engine.py +++ b/api/oas_generator/ts_oas_generator/generator/template_engine.py @@ -385,13 +385,13 @@ def _process_parameters(self, params: list[Schema], spec: Schema) -> list[Parame # Extract parameter details raw_name = str(param.get("name")) - # Skip `format` query param when it's constrained to only msgpack + # Skip `format` query param when it's constrained to a single format (json or msgpack) location_candidate = param.get(constants.OperationKey.IN, constants.ParamLocation.QUERY) if location_candidate == constants.ParamLocation.QUERY and raw_name == constants.FORMAT_PARAM_NAME: schema_obj = param.get("schema", {}) or {} enum_vals = schema_obj.get(constants.SchemaKey.ENUM) - if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] == "msgpack": - # Endpoint only supports msgpack; do not expose/append `format` + if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] in ("msgpack", "json"): + # Endpoint only supports a single format; do not expose/append `format` continue var_name = self._sanitize_variable_name(ts_camel_case(raw_name), used_names) used_names.add(var_name) diff --git a/api/scripts/convert-openapi.ts b/api/scripts/convert-openapi.ts index 19f49b566..4ce4a55c1 100644 --- a/api/scripts/convert-openapi.ts +++ b/api/scripts/convert-openapi.ts @@ -41,7 +41,7 @@ interface FieldTransform { addItems?: Record; // properties to add to the target property, e.g., {"x-custom": true} } -interface MsgpackOnlyEndpoint { +interface FilterEndpoint { path: string; // Exact path to match (e.g., "/v2/blocks/{round}") methods?: string[]; // HTTP methods to apply to (default: ["get"]) } @@ -54,7 +54,8 @@ interface ProcessingConfig { vendorExtensionTransforms?: VendorExtensionTransform[]; requiredFieldTransforms?: RequiredFieldTransform[]; fieldTransforms?: FieldTransform[]; - msgpackOnlyEndpoints?: MsgpackOnlyEndpoint[]; + msgpackOnlyEndpoints?: FilterEndpoint[]; + jsonOnlyEndpoints?: FilterEndpoint[]; // If true, strip APIVn prefixes from component schemas and update refs (KMD) stripKmdApiVersionPrefixes?: boolean; } @@ -480,18 +481,18 @@ function transformRequiredFields(spec: OpenAPISpec, requiredFieldTransforms: Req } /** - * Enforce msgpack-only format for specific endpoints by removing JSON support - * - * This function modifies endpoints to only support msgpack format, aligning with - * Go and JavaScript SDK implementations that hardcode these endpoints to msgpack. + * Enforce a single endpoint format (json or msgpack) by stripping the opposite one */ -function enforceMsgpackOnlyEndpoints(spec: OpenAPISpec, endpoints: MsgpackOnlyEndpoint[]): number { +function enforceEndpointFormat(spec: OpenAPISpec, endpoints: FilterEndpoint[], targetFormat: "json" | "msgpack"): number { let modifiedCount = 0; if (!spec.paths || !endpoints?.length) { return modifiedCount; } + const targetContentType = targetFormat === "json" ? "application/json" : "application/msgpack"; + const otherContentType = targetFormat === "json" ? "application/msgpack" : "application/json"; + for (const endpoint of endpoints) { const pathObj = spec.paths[endpoint.path]; if (!pathObj) { @@ -507,54 +508,58 @@ function enforceMsgpackOnlyEndpoints(spec: OpenAPISpec, endpoints: MsgpackOnlyEn continue; } - // Look for format parameter in query parameters + // Query parameter: format if (operation.parameters && Array.isArray(operation.parameters)) { for (const param of operation.parameters) { - // Handle both inline parameters and $ref parameters const paramObj = param.$ref ? resolveRef(spec, param.$ref) : param; - if (paramObj && paramObj.name === "format" && paramObj.in === "query") { - // OpenAPI 3.0 has schema property containing the type information const schemaObj = paramObj.schema || paramObj; - - // Check if it has an enum with both json and msgpack if (schemaObj.enum && Array.isArray(schemaObj.enum)) { - if (schemaObj.enum.includes("json") && schemaObj.enum.includes("msgpack")) { - // Remove json from enum, keep only msgpack - schemaObj.enum = ["msgpack"]; - // Update default if it was json - if (schemaObj.default === "json") { - schemaObj.default = "msgpack"; + const values: string[] = schemaObj.enum; + if (values.includes("json") || values.includes("msgpack")) { + if (values.length !== 1 || values[0] !== targetFormat) { + schemaObj.enum = [targetFormat]; + if (schemaObj.default !== targetFormat) schemaObj.default = targetFormat; + modifiedCount++; + console.log(`ℹ️ Enforced ${targetFormat}-only for ${endpoint.path} (${method}) parameter`); } - // Don't modify the description - preserve original documentation - modifiedCount++; - console.log(`ℹ️ Enforced msgpack-only for ${endpoint.path} (${method}) parameter`); } } else if (schemaObj.type === "string" && !schemaObj.enum) { - // If no enum is specified, add one with only msgpack - schemaObj.enum = ["msgpack"]; - schemaObj.default = "msgpack"; - // Don't modify the description - preserve original documentation + schemaObj.enum = [targetFormat]; + schemaObj.default = targetFormat; modifiedCount++; - console.log(`ℹ️ Enforced msgpack-only for ${endpoint.path} (${method}) parameter`); + console.log(`ℹ️ Enforced ${targetFormat}-only for ${endpoint.path} (${method}) parameter`); } } } } - // Also check for format in response content types + // Request body content types + if (operation.requestBody && typeof operation.requestBody === "object") { + const rbRaw: any = operation.requestBody; + const rb: any = rbRaw.$ref ? resolveRef(spec, rbRaw.$ref) || rbRaw : rbRaw; + if (rb && rb.content && rb.content[otherContentType] && rb.content[targetContentType]) { + delete rb.content[otherContentType]; + modifiedCount++; + console.log(`ℹ️ Removed ${otherContentType} request content-type for ${endpoint.path} (${method})`); + } + } + + // Response content types if (operation.responses) { for (const [statusCode, response] of Object.entries(operation.responses)) { if (response && typeof response === "object") { const responseObj = response as any; - - // If response has content with both json and msgpack, remove json - if (responseObj.content) { - if (responseObj.content["application/json"] && responseObj.content["application/msgpack"]) { - delete responseObj.content["application/json"]; - modifiedCount++; - console.log(`ℹ️ Removed JSON response content-type for ${endpoint.path} (${method}) - ${statusCode}`); - } + const responseTarget: any = responseObj.$ref ? resolveRef(spec, responseObj.$ref) || responseObj : responseObj; + if ( + responseTarget && + responseTarget.content && + responseTarget.content[otherContentType] && + responseTarget.content[targetContentType] + ) { + delete responseTarget.content[otherContentType]; + modifiedCount++; + console.log(`ℹ️ Removed ${otherContentType} response content-type for ${endpoint.path} (${method}) - ${statusCode}`); } } } @@ -832,10 +837,16 @@ class OpenAPIProcessor { // 9. Enforce msgpack-only endpoints if configured if (this.config.msgpackOnlyEndpoints && this.config.msgpackOnlyEndpoints.length > 0) { - const msgpackCount = enforceMsgpackOnlyEndpoints(spec, this.config.msgpackOnlyEndpoints); + const msgpackCount = enforceEndpointFormat(spec, this.config.msgpackOnlyEndpoints, "msgpack"); console.log(`ℹ️ Enforced msgpack-only format for ${msgpackCount} endpoint parameters/responses`); } + // 10. Enforce json-only endpoints if configured + if (this.config.jsonOnlyEndpoints && this.config.jsonOnlyEndpoints.length > 0) { + const jsonCount = enforceEndpointFormat(spec, this.config.jsonOnlyEndpoints, "json"); + console.log(`ℹ️ Enforced json-only format for ${jsonCount} endpoint parameters/responses`); + } + // Save the processed spec await SwaggerParser.validate(JSON.parse(JSON.stringify(spec))); console.log("✅ Specification is valid"); @@ -992,6 +1003,10 @@ async function processAlgodSpec() { { path: "/v2/deltas/txn/group/{id}", methods: ["get"] }, { path: "/v2/deltas/{round}/txn/group", methods: ["get"] }, ], + jsonOnlyEndpoints: [ + { path: "/v2/accounts/{address}", methods: ["get"] }, + { path: "/v2/accounts/{address}/assets/{asset-id}", methods: ["get"] }, + ], }; await processAlgorandSpec(config); diff --git a/api/specs/algod.oas3.json b/api/specs/algod.oas3.json index 5d0a2c6b7..804c5fb03 100644 --- a/api/specs/algod.oas3.json +++ b/api/specs/algod.oas3.json @@ -280,9 +280,9 @@ "schema": { "type": "string", "enum": [ - "json", - "msgpack" - ] + "json" + ], + "default": "json" } } ], @@ -294,11 +294,6 @@ "schema": { "$ref": "#/components/schemas/Account" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/Account" - } } } }, @@ -309,11 +304,6 @@ "schema": { "$ref": "#/components/schemas/ErrorResponse" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } } } }, @@ -324,11 +314,6 @@ "schema": { "$ref": "#/components/schemas/ErrorResponse" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } } } }, @@ -339,11 +324,6 @@ "schema": { "$ref": "#/components/schemas/ErrorResponse" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } } } }, @@ -395,9 +375,9 @@ "schema": { "type": "string", "enum": [ - "json", - "msgpack" - ] + "json" + ], + "default": "json" } } ], @@ -426,28 +406,6 @@ } } } - }, - "application/msgpack": { - "schema": { - "required": [ - "round" - ], - "type": "object", - "properties": { - "round": { - "type": "integer", - "description": "The round for which this information is relevant.", - "x-go-type": "basics.Round", - "x-algokit-bigint": true - }, - "asset-holding": { - "$ref": "#/components/schemas/AssetHolding" - }, - "created-asset": { - "$ref": "#/components/schemas/AssetParams" - } - } - } } } }, @@ -458,11 +416,6 @@ "schema": { "$ref": "#/components/schemas/ErrorResponse" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } } } }, @@ -473,11 +426,6 @@ "schema": { "$ref": "#/components/schemas/ErrorResponse" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } } } }, @@ -488,11 +436,6 @@ "schema": { "$ref": "#/components/schemas/ErrorResponse" } - }, - "application/msgpack": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } } } }, @@ -802,7 +745,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], @@ -921,7 +865,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], @@ -2876,7 +2821,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], @@ -2976,7 +2922,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], @@ -3058,7 +3005,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], @@ -3160,7 +3108,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], @@ -3271,7 +3220,8 @@ "type": "string", "enum": [ "msgpack" - ] + ], + "default": "msgpack" } } ], diff --git a/crates/algod_client/src/apis/account_asset_information.rs b/crates/algod_client/src/apis/account_asset_information.rs index e2f5b6b2e..9f39a14d5 100644 --- a/crates/algod_client/src/apis/account_asset_information.rs +++ b/crates/algod_client/src/apis/account_asset_information.rs @@ -12,9 +12,7 @@ use algokit_http_client::{HttpClient, HttpMethod}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::parameter_enums::*; use super::{AlgodApiError, ContentType, Error}; -use algokit_transact::AlgorandMsgpack; // Import all custom types used by this endpoint use crate::models::{AccountAssetInformation, ErrorResponse}; @@ -39,11 +37,9 @@ pub async fn account_asset_information( http_client: &dyn HttpClient, address: &str, asset_id: u64, - format: Option, ) -> Result { let p_address = address; let p_asset_id = asset_id; - let p_format = format; let path = format!( "/v2/accounts/{address}/assets/{asset_id}", @@ -51,19 +47,10 @@ pub async fn account_asset_information( asset_id = p_asset_id ); - let mut query_params: HashMap = HashMap::new(); - if let Some(value) = p_format { - query_params.insert("format".to_string(), value.to_string()); - } - - let use_msgpack = p_format.map(|f| f != Format::Json).unwrap_or(true); + let query_params: HashMap = HashMap::new(); let mut headers: HashMap = HashMap::new(); - if use_msgpack { - headers.insert("Accept".to_string(), "application/msgpack".to_string()); - } else { - headers.insert("Accept".to_string(), "application/json".to_string()); - } + headers.insert("Accept".to_string(), "application/json".to_string()); let body = None; @@ -88,8 +75,8 @@ pub async fn account_asset_information( ContentType::Json => serde_json::from_slice(&response.body).map_err(|e| Error::Serde { message: e.to_string(), }), - ContentType::MsgPack => rmp_serde::from_slice(&response.body).map_err(|e| Error::Serde { - message: e.to_string(), + ContentType::MsgPack => Err(Error::Serde { + message: "MsgPack not supported".to_string(), }), ContentType::Text => { let text = String::from_utf8(response.body).map_err(|e| Error::Serde { diff --git a/crates/algod_client/src/apis/account_information.rs b/crates/algod_client/src/apis/account_information.rs index db101b61c..f20280252 100644 --- a/crates/algod_client/src/apis/account_information.rs +++ b/crates/algod_client/src/apis/account_information.rs @@ -14,7 +14,6 @@ use std::collections::HashMap; use super::parameter_enums::*; use super::{AlgodApiError, ContentType, Error}; -use algokit_transact::AlgorandMsgpack; // Import all custom types used by this endpoint use crate::models::{Account, ErrorResponse}; @@ -39,11 +38,9 @@ pub async fn account_information( http_client: &dyn HttpClient, address: &str, exclude: Option, - format: Option, ) -> Result { let p_address = address; let p_exclude = exclude; - let p_format = format; let path = format!( "/v2/accounts/{address}", @@ -54,18 +51,9 @@ pub async fn account_information( if let Some(value) = p_exclude { query_params.insert("exclude".to_string(), value.to_string()); } - if let Some(value) = p_format { - query_params.insert("format".to_string(), value.to_string()); - } - - let use_msgpack = p_format.map(|f| f != Format::Json).unwrap_or(true); let mut headers: HashMap = HashMap::new(); - if use_msgpack { - headers.insert("Accept".to_string(), "application/msgpack".to_string()); - } else { - headers.insert("Accept".to_string(), "application/json".to_string()); - } + headers.insert("Accept".to_string(), "application/json".to_string()); let body = None; @@ -90,8 +78,8 @@ pub async fn account_information( ContentType::Json => serde_json::from_slice(&response.body).map_err(|e| Error::Serde { message: e.to_string(), }), - ContentType::MsgPack => rmp_serde::from_slice(&response.body).map_err(|e| Error::Serde { - message: e.to_string(), + ContentType::MsgPack => Err(Error::Serde { + message: "MsgPack not supported".to_string(), }), ContentType::Text => { let text = String::from_utf8(response.body).map_err(|e| Error::Serde { diff --git a/crates/algod_client/src/apis/client.rs b/crates/algod_client/src/apis/client.rs index e4c08d1a2..bd1fcc159 100644 --- a/crates/algod_client/src/apis/client.rs +++ b/crates/algod_client/src/apis/client.rs @@ -151,13 +151,11 @@ impl AlgodClient { &self, address: &str, exclude: Option, - format: Option, ) -> Result { let result = super::account_information::account_information( self.http_client.as_ref(), address, exclude, - format, ) .await; @@ -169,13 +167,11 @@ impl AlgodClient { &self, address: &str, asset_id: u64, - format: Option, ) -> Result { let result = super::account_asset_information::account_asset_information( self.http_client.as_ref(), address, asset_id, - format, ) .await; diff --git a/crates/algod_client/src/models/account_asset_information.rs b/crates/algod_client/src/models/account_asset_information.rs index 98c9eac06..5cc07aeab 100644 --- a/crates/algod_client/src/models/account_asset_information.rs +++ b/crates/algod_client/src/models/account_asset_information.rs @@ -9,15 +9,8 @@ */ use crate::models; -#[cfg(not(feature = "ffi_uniffi"))] -use algokit_transact::SignedTransaction as AlgokitSignedTransaction; use serde::{Deserialize, Serialize}; -#[cfg(feature = "ffi_uniffi")] -use algokit_transact_ffi::SignedTransaction as AlgokitSignedTransaction; - -use algokit_transact::AlgorandMsgpack; - use crate::models::AssetHolding; use crate::models::AssetParams; @@ -34,10 +27,6 @@ pub struct AccountAssetInformation { pub created_asset: Option, } -impl AlgorandMsgpack for AccountAssetInformation { - const PREFIX: &'static [u8] = b""; // Adjust prefix as needed for your specific type -} - impl AccountAssetInformation { /// Constructor for AccountAssetInformation pub fn new(round: u64) -> AccountAssetInformation { @@ -47,14 +36,4 @@ impl AccountAssetInformation { created_asset: None, } } - - /// Encode this struct to msgpack bytes using AlgorandMsgpack trait - pub fn to_msgpack(&self) -> Result, Box> { - Ok(self.encode()?) - } - - /// Decode msgpack bytes to this struct using AlgorandMsgpack trait - pub fn from_msgpack(bytes: &[u8]) -> Result> { - Ok(Self::decode(bytes)?) - } } diff --git a/crates/algokit_utils/src/clients/asset_manager.rs b/crates/algokit_utils/src/clients/asset_manager.rs index e6a9e2c88..bbd713d6f 100644 --- a/crates/algokit_utils/src/clients/asset_manager.rs +++ b/crates/algokit_utils/src/clients/asset_manager.rs @@ -201,7 +201,7 @@ impl AssetManager { ) -> Result { let sender_str = sender.to_string(); self.algod_client - .account_asset_information(sender_str.as_str(), asset_id, None) + .account_asset_information(sender_str.as_str(), asset_id) .await .map_err(|error| { map_account_asset_information_error(&error, sender_str.as_str(), asset_id) diff --git a/crates/algokit_utils/tests/common/local_net_dispenser.rs b/crates/algokit_utils/tests/common/local_net_dispenser.rs index d1d17acd3..7ef03aa9b 100644 --- a/crates/algokit_utils/tests/common/local_net_dispenser.rs +++ b/crates/algokit_utils/tests/common/local_net_dispenser.rs @@ -120,7 +120,7 @@ impl LocalNetDispenser { let mut highest_balance = 0u64; for address in &addresses { - match self.client.account_information(address, None, None).await { + match self.client.account_information(address, None).await { Ok(info) => { if info.amount > highest_balance { highest_balance = info.amount; diff --git a/crates/algokit_utils/tests/transactions/composer/asset_freeze.rs b/crates/algokit_utils/tests/transactions/composer/asset_freeze.rs index e85790570..fec69e59c 100644 --- a/crates/algokit_utils/tests/transactions/composer/asset_freeze.rs +++ b/crates/algokit_utils/tests/transactions/composer/asset_freeze.rs @@ -148,7 +148,7 @@ async fn test_asset_freeze_unfreeze( // Step 6: Verify account holding shows asset is frozen via algod API let account_info = algorand_fixture .algod - .account_information(&target_addr.to_string(), None, None) + .account_information(&target_addr.to_string(), None) .await?; let assets = account_info.assets.expect("Account should have assets"); @@ -227,7 +227,7 @@ async fn test_asset_freeze_unfreeze( // Step 10: Verify account holding shows asset is no longer frozen via algod API let account_info_after = algorand_fixture .algod - .account_information(&target_addr.to_string(), None, None) + .account_information(&target_addr.to_string(), None) .await?; let assets_after = account_info_after diff --git a/crates/algokit_utils/tests/transactions/composer/key_registration.rs b/crates/algokit_utils/tests/transactions/composer/key_registration.rs index 14cabe541..69fd3bd64 100644 --- a/crates/algokit_utils/tests/transactions/composer/key_registration.rs +++ b/crates/algokit_utils/tests/transactions/composer/key_registration.rs @@ -57,7 +57,7 @@ async fn test_offline_key_registration_transaction( // Verify account participation status let account_info = algorand_fixture .algod - .account_information(&sender_addr.to_string(), None, None) + .account_information(&sender_addr.to_string(), None) .await?; // For offline registration, participation should be empty/none @@ -131,7 +131,7 @@ async fn test_non_participation_key_registration_transaction( // Verify account is now online let account_info = algorand_fixture .algod - .account_information(&sender_addr.to_string(), None, None) + .account_information(&sender_addr.to_string(), None) .await?; assert!( @@ -185,7 +185,7 @@ async fn test_non_participation_key_registration_transaction( // Verify account participation status let account_info = algorand_fixture .algod - .account_information(&sender_addr.to_string(), None, None) + .account_information(&sender_addr.to_string(), None) .await?; // For non-participation, participation should be empty/none @@ -344,7 +344,7 @@ async fn test_online_key_registration_transaction( // Verify account participation status let account_info = algorand_fixture .algod - .account_information(&sender_addr.to_string(), None, None) + .account_information(&sender_addr.to_string(), None) .await?; // For online registration, participation should contain the keys diff --git a/packages/typescript/algod_client/src/apis/api.service.ts b/packages/typescript/algod_client/src/apis/api.service.ts index e487ec1eb..e2b2a64fd 100644 --- a/packages/typescript/algod_client/src/apis/api.service.ts +++ b/packages/typescript/algod_client/src/apis/api.service.ts @@ -162,18 +162,17 @@ export class AlgodApi { async accountAssetInformation( address: string, assetId: number | bigint, - params?: { format?: 'json' | 'msgpack' }, requestOptions?: ApiRequestOptions, ): Promise { const headers: Record = {} - const responseFormat: BodyFormat = (params?.format as BodyFormat | undefined) ?? 'msgpack' + const responseFormat: BodyFormat = 'json' headers['Accept'] = AlgodApi.acceptFor(responseFormat) const payload = await this.httpRequest.request({ method: 'GET', url: '/v2/accounts/{address}/assets/{asset-id}', path: { address: address, 'asset-id': typeof assetId === 'bigint' ? assetId.toString() : assetId }, - query: { format: params?.format }, + query: {}, headers, body: undefined, mediaType: undefined, @@ -220,20 +219,16 @@ export class AlgodApi { /** * Given a specific account public key, this call returns the account's status, balance and spendable amounts */ - async accountInformation( - address: string, - params?: { exclude?: 'all' | 'none'; format?: 'json' | 'msgpack' }, - requestOptions?: ApiRequestOptions, - ): Promise { + async accountInformation(address: string, params?: { exclude?: 'all' | 'none' }, requestOptions?: ApiRequestOptions): Promise { const headers: Record = {} - const responseFormat: BodyFormat = (params?.format as BodyFormat | undefined) ?? 'msgpack' + const responseFormat: BodyFormat = 'json' headers['Accept'] = AlgodApi.acceptFor(responseFormat) const payload = await this.httpRequest.request({ method: 'GET', url: '/v2/accounts/{address}', path: { address: address }, - query: { exclude: params?.exclude, format: params?.format }, + query: { exclude: params?.exclude }, headers, body: undefined, mediaType: undefined,