diff --git a/.vscode/settings.json b/.vscode/settings.json index f8b8e796..65a16046 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,8 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": true, + "source.organizeImports": true }, "jest.jestCommandLine": "npm run test --", "jest.autoRun": { diff --git a/src/account.ts b/src/account.ts new file mode 100644 index 00000000..7542c96a --- /dev/null +++ b/src/account.ts @@ -0,0 +1,152 @@ +import algosdk, { Account, Algodv2, Kmd } from 'algosdk' +import { AlgoAmount } from './algo-amount' +import { AlgoKitConfig } from './config' +import { getLocalNetDispenserAccount, getOrCreateKmdWalletAccount } from './localnet' +import { isLocalNet } from './network-client' +import { SigningAccount } from './transaction' +import { transferAlgos } from './transfer' + +/** + * The account name identifier used for fund dispensing in test environments + */ +export const DISPENSER_ACCOUNT = 'DISPENSER' + +/** Returns an Algorand account with secret key loaded (i.e. that can sign transactions) by taking the mnemonic secret. + * + * This is a wrapper around algosdk.mnemonicToSecretKey to provide a more friendly/obvious name. + * + * @param mnemonicSecret The mnemonic secret representing the private key of an account; **Note: Be careful how the mnemonic is handled**, + * never commit it into source control and ideally load it from the environment (ideally via a secret storage service) rather than the file system. + */ +export function getAccountFromMnemonic(mnemonicSecret: string): Account { + // This method is confusingly named, so this function provides a more dev friendly "wrapper" name + return algosdk.mnemonicToSecretKey(mnemonicSecret) +} + +/** + * Returns an Algorand account with private key loaded by convention based on the given name identifier. + * + * Note: This function expects to run in a Node.js environment. + * + * ## Convention: + * * **Non-LocalNet:** will load process.env['{NAME}_MNEMONIC'] as a mnemonic secret; **Note: Be careful how the mnemonic is handled**, + * never commit it into source control and ideally load it via a secret storage service rather than the file system. + * If process.env['{NAME}_SENDER'] is defined then it will use that for the sender address (i.e. to support rekeyed accounts) + * * **LocalNet:** will load the account from a KMD wallet called {NAME} and if that wallet doesn't exist it will create it and fund the account for you + * + * This allows you to write code that will work seamlessly in production and local development (LocalNet) without manual config locally (including when you reset the LocalNet). + * + * @example Default + * + * If you have a mnemonic secret loaded into `process.env.ACCOUNT_MNEMONIC` then you can call the following to get that private key loaded into an account object: + * ``` + * const account = await getAccount(client, 'ACCOUNT') + * ``` + * + * If that code runs against LocalNet then a wallet called `ACCOUNT` will automatically be created with an account that is automatically funded with 1000 (default) ALGOs from the default LocalNet dispenser. + * + * @param client An algod client + * @param name The name identifier for the account + * @param fundWith The amount to fund the account with it it gets created (when targeting LocalNet), if not specified then 1000 Algos will be funded from the dispenser account @see {getDispenserAccount} + * @param kmdClient A KMD client to use to create an account (when targeting LocalNet), if not specified then a default KMD client will be loaded from environment variables @see {getAlgoKmdClient} + * @returns The requested account with private key loaded from the environment variables or when targeting LocalNet from KMD (idempotently creating and funding the account) + */ +export async function getAccount(client: Algodv2, name: string, fundWith?: AlgoAmount, kmdClient?: Kmd): Promise { + if (!process || !process.env) { + throw new Error('Attempt to get account with private key from a non Node.js context; not supported!') + } + + const envKey = `${name.toUpperCase()}_MNEMONIC` + if (process.env[envKey]) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const signer = getAccountFromMnemonic(process.env[envKey]!) + const senderKey = `${name.toUpperCase()}_SENDER` + if (process.env[senderKey]) { + AlgoKitConfig.logger.debug(`Using rekeyed account ${signer.addr} for sender ${process.env[senderKey]} for ${name} account`) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return new SigningAccount(signer, process.env[senderKey]!) + } else { + return signer + } + } + + if (await isLocalNet(client)) { + const account = await getOrCreateKmdWalletAccount(client, name, fundWith, kmdClient) + process.env[envKey] = algosdk.secretKeyToMnemonic(account.sk) + return account + } + + throw `Missing environment variable ${envKey} when looking for account ${name}` +} + +/** + * Parameters for the getTestAccount function. + */ +interface GetTestAccountParams { + /** Algodv2 client */ + client: Algodv2 + /** Initial funds to ensure the account has */ + initialFunds: AlgoAmount + /** Whether to suppress the log (which includes a mnemonic) or not (default: do not supress the log) */ + suppressLog?: boolean +} + +/** + * Creates an ephemeral Algorand account for the purposes of testing. + * Returns a newly created random test account that is funded from the dispenser @see {getDispenserAccount} + * DO NOT USE THIS TO CREATE A MAINNET ACCOUNT! + * Note: By default this will log the mnemonic of the account. + * @param param0 The config for the test account to generate + * @returns The account, with private key loaded + */ +export async function getTestAccount({ client, suppressLog, initialFunds }: GetTestAccountParams): Promise { + const account = algosdk.generateAccount() + if (!suppressLog) { + AlgoKitConfig.logger.info( + `New test account created with address '${account.addr}' and mnemonic '${algosdk.secretKeyToMnemonic(account.sk)}'.`, + ) + } + + // If we are running against LocalNet we can use the default account within it + // otherwise use an automation account specified via environment variables and ensure it's populated with ALGOs + const canFundFromDefaultAccount = await isLocalNet(client) + const dispenser = canFundFromDefaultAccount ? await getLocalNetDispenserAccount(client) : await getAccount(client, DISPENSER_ACCOUNT) + + await transferAlgos({ from: dispenser, to: account.addr, amount: initialFunds, note: 'Funding test account', suppressLog }, client) + + const accountInfo = await client.accountInformation(account.addr).do() + if (!suppressLog) { + AlgoKitConfig.logger.info('Test account funded; account balance: %d µAlgos', accountInfo.amount) + } + + return account +} + +/** Returns an account's address as a byte array + * + * @param account Either an account (with private key loaded) or the string address of an account + */ +export function getAccountAddressAsUint8Array(account: Account | string) { + return algosdk.decodeAddress(typeof account === 'string' ? account : account.addr).publicKey +} + +/** Returns the string address of an Algorand account from a base64 encoded version of the underlying byte array of the address public key + * + * @param addressEncodedInB64 The base64 encoded version of the underlying byte array of the address public key + */ +export function getAccountAddressAsString(addressEncodedInB64: string): string { + return algosdk.encodeAddress(Buffer.from(addressEncodedInB64, 'base64')) +} + +/** Returns an account (with private key loaded) that can act as a dispenser + * + * If running on Sandbox then it will return the default dispenser account automatically, + * otherwise it will load the account mnemonic stored in process.env.DISPENSER_MNEMONIC @see {getAccount} + * + * @param client An algod client + */ +export async function getDispenserAccount(client: Algodv2) { + // If we are running against a sandbox we can use the default account within it, otherwise use an automation account specified via environment variables and ensure it's populated with ALGOs + const canFundFromDefaultAccount = await isLocalNet(client) + return canFundFromDefaultAccount ? await getLocalNetDispenserAccount(client) : await getAccount(client, DISPENSER_ACCOUNT) +} diff --git a/src/algo-amount.ts b/src/algo-amount.ts new file mode 100644 index 00000000..63025290 --- /dev/null +++ b/src/algo-amount.ts @@ -0,0 +1,30 @@ +import algosdk from 'algosdk' + +/** Wrapper class to ensure safe, explicit conversion between µAlgos, Algos and numbers */ +export class AlgoAmount { + private amountInMicroAlgos + + /** Return the amount as a number in µAlgos */ + get microAlgos() { + return this.amountInMicroAlgos + } + + /** Return the amount as a number in Algos */ + get algos() { + return algosdk.microalgosToAlgos(this.amountInMicroAlgos) + } + + constructor(amount: { algos: number } | { microAlgos: number }) { + this.amountInMicroAlgos = 'microAlgos' in amount ? amount.microAlgos : algosdk.algosToMicroalgos(amount.algos) + } + + /** Create a @see {AlgoAmount} object representing the given number of Algos */ + static Algos(amount: number) { + return new AlgoAmount({ algos: amount }) + } + + /** Create a @see {AlgoAmount} object representing the given number of µAlgos */ + static MicroAlgos(amount: number) { + return new AlgoAmount({ microAlgos: amount }) + } +} diff --git a/src/algo-http-client-with-retry.ts b/src/algo-http-client-with-retry.ts index ca40ffc7..22536260 100644 --- a/src/algo-http-client-with-retry.ts +++ b/src/algo-http-client-with-retry.ts @@ -1,4 +1,5 @@ import { BaseHTTPClientResponse, Query } from 'algosdk/dist/types/client/baseHTTPClient' +import { AlgoKitConfig } from './config' import { URLTokenBaseHTTPClient } from './urlTokenBaseHTTPClient' /** A HTTP Client that wraps the Algorand SDK HTTP Client with retries */ @@ -27,6 +28,7 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { do { try { response = await func() + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (numTries >= AlgoHttpClientWithRetry.MAX_TRIES) { throw err @@ -42,9 +44,10 @@ export class AlgoHttpClientWithRetry extends URLTokenBaseHTTPClient { if (delayTimeMs > 0) { await new Promise((r) => setTimeout(r, delayTimeMs)) } - console.warn(`algosdk request failed ${numTries} times. Retrying in ${delayTimeMs}ms: ${err}`) + AlgoKitConfig.logger.warn(`algosdk request failed ${numTries} times. Retrying in ${delayTimeMs}ms: ${err}`) } } while (!response && ++numTries <= AlgoHttpClientWithRetry.MAX_TRIES) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return response! } diff --git a/src/index.ts b/src/index.ts index 5a61390e..285c5823 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,7 @@ +export * from './account' +export * from './algo-amount' export * from './config' -export * from './network-clients' +export * from './localnet' +export * from './network-client' +export * from './transaction' +export * from './transfer' diff --git a/src/indexer-lookup.ts b/src/indexer-lookup.ts new file mode 100644 index 00000000..37ff61e0 --- /dev/null +++ b/src/indexer-lookup.ts @@ -0,0 +1,12 @@ +import { Indexer } from 'algosdk' +import { TransactionLookupResult } from './indexer-type' + +/** + * Looks up a transaction by ID using Indexer. + * @param indexer An indexer client + * @param transactionId The ID of the transaction to look up + * @returns The result of the look-up + */ +export async function lookupTransactionById(indexer: Indexer, transactionId: string): Promise { + return (await indexer.lookupTransactionByID(transactionId).do()) as TransactionLookupResult +} diff --git a/src/indexer-type.ts b/src/indexer-type.ts new file mode 100644 index 00000000..c3956653 --- /dev/null +++ b/src/indexer-type.ts @@ -0,0 +1,336 @@ +import { TransactionType } from 'algosdk' +import { TealKeyValue } from 'algosdk/dist/types/client/v2/algod/models/types' + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2transactions */ +export interface TransactionSearchResults { + 'current-round': string + 'next-token': string + transactions: TransactionResult[] +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2accountsaccount-id */ +export interface AccountLookupResult { + 'current-round': string + account: AccountResult +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2accountsaccount-idassets */ +export interface AssetsLookupResult { + 'current-round': string + 'next-token': string + assets: AssetHolding[] +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2accountsaccount-idcreated-assets */ +export interface AssetsCreatedLookupResult { + 'current-round': string + 'next-token': string + assets: AssetResult[] +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2accountsaccount-idcreated-applications */ +export interface ApplicationCreatedLookupResult { + 'current-round': string + 'next-token': string + applications: ApplicationResult[] +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2assetsasset-id */ +export interface AssetLookupResult { + 'current-round': string + asset: AssetResult +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2transactionstxid */ +export interface TransactionLookupResult { + 'current-round': number + transaction: TransactionResult +} + +/** https://developer.algorand.org/docs/rest-apis/indexer/#get-v2applicationsapplication-id */ +export interface ApplicationLookupResult { + 'current-round': string + application: ApplicationResult +} + +/** Indexer result for a transaction, @see https://developer.algorand.org/docs/rest-apis/indexer/#transaction */ +export interface TransactionResult { + id: string + fee: number + sender: string + 'first-valid': number + 'last-valid': number + 'confirmed-round'?: number + group?: string + note?: string + logs?: string[] + 'round-time'?: number + 'intra-round-offset'?: number + signature?: TransactionSignature + 'application-transaction'?: ApplicationTransactionResult + 'created-application-index'?: number + 'asset-config-transaction': AssetConfigTransactionResult + 'created-asset-index'?: number + 'asset-freeze-transaction'?: AssetFreezeTransactionResult + 'asset-transfer-transaction'?: AssetTransferTransactionResult + 'keyreg-transaction'?: any + 'payment-transaction'?: PaymentTransactionResult + 'auth-addr'?: string + 'closing-amount'?: number + 'genesis-hash'?: string + 'genesis-id'?: string + 'inner-txns'?: TransactionResult[] + 'rekey-to'?: string + lease?: string + 'local-state-delta'?: Record[] + 'global-state-delta'?: Record[] + 'receiver-rewards'?: number + 'sender-rewards'?: number + 'close-rewards'?: number + 'tx-type': TransactionType +} + +export interface AccountResult { + address: string + amount: number + 'amount-without-pending-rewards': number + 'apps-local-state'?: AppLocalState[] + 'apps-total-extra-pages'?: number + 'apps-total-schema'?: StateSchema + 'auth-addr'?: string + 'closed-at-round'?: number + 'created-at-round'?: number + deleted?: boolean + participation: any + 'pending-rewards': number + 'reward-base': number + rewards: number + round: number + 'sig-type': SignatureType + status: AccountStatus +} + +export interface PaymentTransactionResult { + amount: number + 'close-amount'?: number + 'close-remainder-to'?: string + receiver: string +} + +export interface ApplicationTransactionResult extends Exclude<{ creator: string; 'global-state': TealKeyValue[] }, ApplicationParams> { + 'application-id': number + 'on-completion': ApplicationOnComplete + 'application-args'?: string[] + accounts?: string[] + 'foreign-apps'?: number[] + 'foreign-assets'?: number[] +} + +export interface AssetConfigTransactionResult { + 'asset-id': number + params: AssetParams +} + +export interface AssetFreezeTransactionResult { + address: string + 'asset-id': number + 'new-freeze-status': boolean +} + +export interface AssetTransferTransactionResult { + amount: number + 'asset-id': number + 'close-amount'?: number + 'close-to'?: string + receiver?: string + sender?: string +} + +export interface AssetResult { + index: number + deleted?: boolean + 'created-at-round': number + 'deleted-at-round': number + params: AssetParams +} + +export interface ApplicationResult { + id: number + params: ApplicationParams + 'created-at-round'?: number + deleted?: boolean + 'deleted-at-round'?: number +} + +interface TransactionSignature { + logicsig: LogicTransactionSignature + multisig: MultisigTransactionSignature + sig: string +} + +interface LogicTransactionSignature { + args: string[] + logic: string + 'multisig-signature': MultisigTransactionSignature + signature: string +} + +interface MultisigTransactionSignature { + subsignature: MultisigTransactionSubSignature + threshold: number + version: number +} + +interface MultisigTransactionSubSignature { + 'public-key': string + signature: string +} + +export interface EvalDelta { + action: number + bytes: string + uint: number +} + +interface ApplicationParams { + creator: string + 'approval-program': string + 'clear-state-program': string + 'extra-program-pages'?: number + 'global-state': TealKeyValue[] + 'global-state-schema'?: StateSchema + 'local-state-schema'?: StateSchema +} + +interface StateSchema { + 'num-byte-slice': number + 'num-uint': number +} + +export enum ApplicationOnComplete { + noop = 'noop', + optin = 'optin', + closeout = 'closeout', + clear = 'clear', + update = 'update', + delete = 'delete', +} + +interface AssetParams { + /** + * The address that created this 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 in the worst case. + */ + creator: string + /** + * (dc) The number of digits to use after the decimal point when displaying this + * asset. 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, and so on. This value + * must be between 0 and 19 (inclusive). + */ + decimals: number | bigint + /** + * (t) The total number of units of this asset. + */ + total: number | bigint + /** + * (c) Address of account used to clawback holdings of this asset. If empty, + * clawback is not permitted. + */ + clawback?: string + /** + * (df) Whether holdings of this asset are frozen by default. + */ + 'default-frozen'?: boolean + /** + * (f) Address of account used to freeze holdings of this asset. If empty, freezing + * is not permitted. + */ + freeze?: string + /** + * (m) Address of account used to manage the keys of this asset and to destroy it. + */ + manager?: string + /** + * (am) A commitment to some unspecified asset metadata. The format of this + * metadata is up to the application. + */ + 'metadata-hash'?: Uint8Array + /** + * (an) Name of this asset, as supplied by the creator. Included only when the + * asset name is composed of printable utf-8 characters. + */ + name?: string + /** + * Base64 encoded name of this asset, as supplied by the creator. + */ + 'name-b64'?: Uint8Array + /** + * (r) Address of account holding reserve (non-minted) units of this asset. + */ + reserve?: string + /** + * (un) Name of a unit of this asset, as supplied by the creator. Included only + * when the name of a unit of this asset is composed of printable utf-8 characters. + */ + 'unit-name'?: string + /** + * Base64 encoded name of a unit of this asset, as supplied by the creator. + */ + 'unit-name-b64'?: Uint8Array + /** + * (au) URL where more information about the asset can be retrieved. Included only + * when the URL is composed of printable utf-8 characters. + */ + url?: string + /** + * Base64 encoded URL where more information about the asset can be retrieved. + */ + 'url-b64'?: Uint8Array +} + +export enum SignatureType { + sig = 'sig', + msig = 'msig', + lsig = 'lsig', +} + +export enum AccountStatus { + Offline = 'Offline', + Online = 'Online', + NotParticipating = 'NotParticipating', +} + +interface AppLocalState { + 'closed-out-at-round': number + deleted: boolean + id: number + 'key-value': TealKeyValue[] + 'opted-in-at-round': number + schema: StateSchema +} + +export interface AssetHolding { + /** + * (a) number of units held. + */ + amount: number + /** + * Asset ID of the holding. + */ + 'asset-id': number + /** + * Address that created this 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 in the worst case. + */ + creator: string + /** + * (f) whether or not the holding is frozen. + */ + 'is-frozen': boolean + deleted?: boolean + 'opted-in-at-round': number + 'opted-out-at-round': number +} diff --git a/src/localnet.ts b/src/localnet.ts new file mode 100644 index 00000000..0b2d3401 --- /dev/null +++ b/src/localnet.ts @@ -0,0 +1,137 @@ +import algosdk, { Account, Algodv2, Kmd } from 'algosdk' +import { getAccountFromMnemonic, getDispenserAccount } from './account' +import { AlgoAmount } from './algo-amount' +import { AlgoKitConfig } from './config' +import { getAlgoKmdClient } from './network-client' +import { transferAlgos } from './transfer' + +/** Returns true if the algod client is pointing to a LocalNet Algorand network */ +export async function isLocalNet(client: Algodv2): Promise { + const params = await client.getTransactionParams().do() + + return params.genesisID === 'devnet-v1' || params.genesisID === 'sandnet-v1' +} + +/** + * Gets an account with private key loaded from a KMD wallet of the given name, or alternatively creates one with funds in it via a KMD wallet of the given name. + * + * This is useful to get idempotent accounts from a local sandbox without having to specify the private key (which will change when resetting the sandbox). + * + * This significantly speeds up local dev time and improves experience since you can write code that *just works* first go without manual config in a fresh sandbox. + * + * If this is used via @see {getAccount}, then you can even use the same code that runs on production without changes for local development! + * + * @param client An algod client + * @param name The name of the wallet to retrieve / create + * @param fundWith The number of Algos to fund the account with it it gets created, if not specified then 1000 Algos will be funded from the dispenser account @see {getDispenserAccount} + * @param kmdClient A KMD client, if not specified then a default KMD client will be loaded from environment variables @see {getAlgoKmdClient} + * + * @returns An Algorand account with private key loaded - either one that already existed in the given KMD wallet, or a new one that is funded for you + */ +export async function getOrCreateKmdWalletAccount(client: Algodv2, name: string, fundWith?: AlgoAmount, kmdClient?: Kmd): Promise { + // Get an existing account from the KMD wallet + const existing = await getKmdWalletAccount(client, name) + if (existing) { + return existing + } + + // None existed: create the KMD wallet instead + const kmd = kmdClient ?? getAlgoKmdClient() + const walletId = (await kmd.createWallet(name, '')).wallet.id + const walletHandle = (await kmd.initWalletHandle(walletId, '')).wallet_handle_token + await kmd.generateKey(walletHandle) + + // Get the account from the new KMD wallet + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const account = (await getKmdWalletAccount(client, name))! + + AlgoKitConfig.logger.info( + `Couldn't find existing account in Sandbox under name '${name}'; created account ${ + account.addr + } with keys stored in KMD and funding with ${fundWith ?? 1000} ALGOs`, + ) + + // Fund the account from the dispenser + await transferAlgos( + { + amount: fundWith ?? AlgoAmount.Algos(1000), + from: await getDispenserAccount(client), + to: account.addr, + }, + client, + ) + + return account +} + +/** + * Returns an Algorand account with private key loaded from the given KMD wallet (identified by name). + * + * @param client An algod client + * @param name The name of the wallet to retrieve an account from + * @param predicate An optional filter to use to find the account (otherwise it will return a random account from the wallet) + * @param kmdClient A KMD client, if not specified then a default KMD client will be loaded from environment variables @see {getAlgoKmdClient} + * @example Get default funded account in a LocalNet + * + * ``` + * const defaultDispenserAccount = await getKmdWalletAccount(client, + * 'unencrypted-default-wallet', + * a => a.status !== 'Offline' && a.amount > 1_000_000_000 + * ) + * ``` + */ +export async function getKmdWalletAccount( + client: Algodv2, + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + predicate?: (account: Record) => boolean, + kmdClient?: Kmd, +): Promise { + const kmd = kmdClient ?? getAlgoKmdClient() + const wallets = await kmd.listWallets() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wallet = wallets.wallets.filter((w: any) => w.name === name) + if (wallet.length === 0) { + return undefined + } + + const walletId = wallet[0].id + + const walletHandle = (await kmd.initWalletHandle(walletId, '')).wallet_handle_token + const keyIds = (await kmd.listKeys(walletHandle)).addresses + + let i = 0 + if (predicate) { + for (i = 0; i < keyIds.length; i++) { + const key = keyIds[i] + const account = await client.accountInformation(key).do() + if (predicate(account)) { + break + } + } + } + + if (i >= keyIds.length) { + return undefined + } + + const accountKey = (await kmd.exportKey(walletHandle, '', keyIds[i])).private_key + + const accountMnemonic = algosdk.secretKeyToMnemonic(accountKey) + return getAccountFromMnemonic(accountMnemonic) +} + +/** + * Returns an Algorand account with private key loaded for the default LocalNet dispenser account (that can be used to fund other accounts) + * + * @param client An algod client + */ +export async function getLocalNetDispenserAccount(client: Algodv2): Promise { + if (!(await isLocalNet(client))) { + throw "Can't get default account from non LocalNet network" + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (await getKmdWalletAccount(client, 'unencrypted-default-wallet', (a) => a.status !== 'Offline' && a.amount > 1_000_000_000))! +} diff --git a/src/network-clients.spec.ts b/src/network-client.spec.ts similarity index 98% rename from src/network-clients.spec.ts rename to src/network-client.spec.ts index c05e4516..5eea2bcc 100644 --- a/src/network-clients.spec.ts +++ b/src/network-client.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, afterAll, beforeEach, jest } from '@jest/globals' +import { afterAll, beforeEach, describe, expect, jest, test } from '@jest/globals' import { getAlgoClient, getAlgodConfigFromEnvironment, @@ -8,7 +8,7 @@ import { getDefaultLocalNetConfig, getIndexerConfigFromEnvironment, isLocalNet, -} from './network-clients' +} from './network-client' describe('network-clients', () => { describe('Config', () => { diff --git a/src/network-clients.ts b/src/network-client.ts similarity index 96% rename from src/network-clients.ts rename to src/network-client.ts index a37d34bf..d3a68f8e 100644 --- a/src/network-clients.ts +++ b/src/network-client.ts @@ -168,9 +168,4 @@ export function getAlgoKmdClient(config?: AlgoClientConfig): Kmd { return new Kmd(token as string, server, process?.env?.KMD_PORT ?? '4002') } -/** Returns true if the algod client is pointing to a sandbox Algorand network */ -export async function isLocalNet(client: Algodv2): Promise { - const params = await client.getTransactionParams().do() - - return params.genesisID === 'devnet-v1' || params.genesisID === 'sandnet-v1' -} +export { isLocalNet } from './localnet' diff --git a/src/transaction.spec.ts b/src/transaction.spec.ts new file mode 100644 index 00000000..34a8487e --- /dev/null +++ b/src/transaction.spec.ts @@ -0,0 +1,60 @@ +import { describe, test } from '@jest/globals' +import algosdk from 'algosdk' +import { localnetFixture as localNetFixture } from '../tests/fixtures/localnet-fixture' +import { AlgoAmount } from './algo-amount' +import { sendTransaction } from './transaction' + +describe('transaction', () => { + const localnet = localNetFixture() + + const getTestTransaction = async () => { + return algosdk.makePaymentTxnWithSuggestedParamsFromObject({ + from: localnet.context.testAccount.addr, + to: localnet.context.testAccount.addr, + amount: 1, + suggestedParams: await localnet.context.client.getTransactionParams().do(), + }) + } + + test('Transaction is sent and waited for', async () => { + const { client, testAccount } = localnet.context + const txn = await getTestTransaction() + const { transaction, confirmation } = await sendTransaction(client, txn, testAccount) + + expect(transaction.txID()).toBe(txn.txID()) + expect(confirmation?.['confirmed-round']).toBeGreaterThanOrEqual(txn.firstRound) + }) + + test('Transaction is capped by low min txn fee', async () => { + const { client, testAccount } = localnet.context + const txn = await getTestTransaction() + await expect(async () => { + await sendTransaction(client, txn, testAccount, { + maxFee: AlgoAmount.MicroAlgos(1), + }) + }).rejects.toThrowError( + 'Cancelled transaction due to high network congestion fees. ' + + 'Algorand suggested fees would cause this transaction to cost 1000 µALGOs. ' + + 'Cap for this transaction is 1 µALGOs.', + ) + }) + + test('Transaction cap is ignored if flat fee set', async () => { + const { client, testAccount } = localnet.context + const txn = await getTestTransaction() + txn.flatFee = true + await sendTransaction(client, txn, testAccount, { + maxFee: AlgoAmount.MicroAlgos(1), + }) + }) + + test('Transaction cap is ignored if higher than fee', async () => { + const { client, testAccount } = localnet.context + const txn = await getTestTransaction() + const { confirmation } = await sendTransaction(client, txn, testAccount, { + maxFee: AlgoAmount.MicroAlgos(1000_000), + }) + + expect(confirmation?.txn.txn.fee).toBe(1000) + }) +}) diff --git a/src/transaction.ts b/src/transaction.ts index e69de29b..e9f543f6 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -0,0 +1,348 @@ +import algosdk, { Account, Algodv2, EncodedSignedTransaction, LogicSigAccount, Transaction } from 'algosdk' +import { AlgoAmount } from './algo-amount' +import { AlgoKitConfig } from './config' + +/** Account wrapper that supports a rekeyed account */ +export class SigningAccount implements Account { + private _account: Account + private _sender: string + + /** + * Algorand address of the sender + */ + get addr(): Readonly { + return this._sender + } + + /** + * Secret key belonging to the signer + */ + get sk(): Readonly { + return this._account.sk + } + + /** + * Algorand account of the underlying signing account + */ + get signer(): Account { + return this._account + } + + /** + * Algorand account of the sender address and signer private key + */ + get sender(): Account { + return { + addr: this._sender, + sk: this._account.sk, + } + } + + constructor(account: Account, sender: string | undefined) { + this._account = account + this._sender = sender ?? account.addr + } +} + +export type TransactionNote = Uint8Array | TransactionNoteData | Arc2TransactionNote +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TransactionNoteData = string | null | undefined | number | any[] | Record +/** ARC-0002 compatible transaction note components, @see https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md */ +export type Arc2TransactionNote = { + dAppName: string + format: 'm' | 'j' | 'b' | 'u' + data: string +} + +/** Encodes a transaction note into a byte array ready to be included in an Algorand transaction. + * + * @param note The transaction note + * @returns the transaction note ready for inclusion in a transaction + * + * Case on the value of `data` this either either be: + * * `null` | `undefined`: `undefined` + * * `string`: The string value + * * Uint8Array: passthrough + * * Arc2TransactionNote object: ARC-0002 compatible transaction note + * * Else: The object/value converted into a JSON string representation + */ +export function encodeTransactionNote(note?: TransactionNote): Uint8Array | undefined { + if (note == null || typeof note === 'undefined') { + return undefined + } else if (typeof note === 'object' && note.constructor === Uint8Array) { + return note + } else if (typeof note === 'object' && 'dAppName' in note) { + const arc2Payload = `${note.dAppName}:${note.format}${note.data}` + const encoder = new TextEncoder() + return encoder.encode(arc2Payload) + } else { + const n = typeof note === 'string' ? note : JSON.stringify(note) + const encoder = new TextEncoder() + return encoder.encode(n) + } +} + +/** The sending configuration for a transaction */ +export interface SendTransactionParams { + /** Whether to skip signing and sending the transaction to the chain (default: transaction signed and sent to chain) + * (and instead just return the raw transaction, e.g. so you can add it to a group of transactions) */ + skipSending?: boolean + /** Whether to skip waiting for the submitted transaction (only relevant if `skipSending` is `false` or unset) */ + skipWaiting?: boolean + /** Whether to suppress log messages from transaction send, default: do not suppress */ + suppressLog?: boolean + /** The maximum fee that you are happy to pay (default: unbounded) - if this is set it's possible the transaction could get rejected during network congestion */ + maxFee?: AlgoAmount + /** The maximum number of rounds to wait for confirmation, only applies if `skipWaiting` is `undefined` or `false`, default: wait up to 5 rounds */ + maxRoundsToWaitForConfirmation?: number +} + +/** The result of sending a transaction */ +export interface SendTransactionResult { + /** The transaction */ + transaction: Transaction + /** The response if the transaction was sent and waited for */ + confirmation?: PendingTransactionResponse +} + +/** Signs and sends the given transaction to the chain + * + * @param client An algod client + * @param transaction The unsigned transaction + * @param from The account to sign the transaction with: either an account with private key loaded or a logic signature account + * @param config The sending configuration for this transaction + * + * @returns An object with transaction (`transaction`) and (if `skipWaiting` is `false` or unset) confirmation (`confirmation`) + */ +export const sendTransaction = async function ( + client: Algodv2, + transaction: Transaction, + from: Account | SigningAccount | LogicSigAccount, + sendParams?: SendTransactionParams, +): Promise { + const { skipSending, skipWaiting, maxFee, suppressLog, maxRoundsToWaitForConfirmation } = sendParams ?? {} + if (maxFee !== undefined) { + capTransactionFee(transaction, maxFee) + } + + if (skipSending) { + return { transaction } + } + + const signedTransaction = 'sk' in from ? transaction.signTxn(from.sk) : algosdk.signLogicSigTransactionObject(transaction, from).blob + await client.sendRawTransaction(signedTransaction).do() + + if (!suppressLog) { + AlgoKitConfig.logger.info( + `Sent transaction ID ${transaction.txID()} ${transaction.type} from ${'sk' in from ? from.addr : from.address()}`, + ) + } + + let confirmation: PendingTransactionResponse | undefined = undefined + if (!skipWaiting) { + confirmation = await waitForConfirmation(client, transaction.txID(), maxRoundsToWaitForConfirmation ?? 5) + } + + return { transaction, confirmation } +} + +/** Defines an unsigned transaction that will appear in a group of transactions along with its signing information */ +export interface TransactionToSign { + /** The unsigned transaction to sign and send */ + transaction: Transaction + /** The account to use to sign the transaction, either an account (with private key loaded) or a logic signature account */ + signer: Account | SigningAccount | LogicSigAccount +} + +/** + * Signs and sends a group of [up to 16](https://developer.algorand.org/docs/get-details/atomic_transfers/#create-transactions) transactions to the chain + * + * @param client An algod client + * @param transactions The array of transactions to send along with their signing account + * @param skipWaiting Whether or not the transaction should be waited until it's confirmed (default: wait for the transaction confirmation) + * @returns An object with group transaction ID (`groupTransactionId`) and (if `skipWaiting` is `false` or unset) confirmation (`confirmation`) + */ +export const sendGroupOfTransactions = async function (client: Algodv2, transactions: TransactionToSign[], skipWaiting = false) { + const transactionsToSend = transactions.map((t) => { + return t.transaction + }) + + const group = algosdk.assignGroupID(transactionsToSend) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const groupId = Buffer.from(group[0].group!).toString('base64') + + AlgoKitConfig.logger.info(`Sending group of transactions (${groupId})`, { transactionsToSend }) + + const signedTransactions = group.map((groupedTransaction, index) => { + const signer = transactions[index].signer + return 'sk' in signer ? groupedTransaction.signTxn(signer.sk) : algosdk.signLogicSigTransactionObject(groupedTransaction, signer).blob + }) + + AlgoKitConfig.logger.debug( + `Signer IDs (${groupId})`, + transactions.map((t) => ('addr' in t.signer ? t.signer.addr : t.signer.address())), + ) + + AlgoKitConfig.logger.debug( + `Transaction IDs (${groupId})`, + transactionsToSend.map((t) => t.txID()), + ) + + // https://developer.algorand.org/docs/rest-apis/algod/v2/#post-v2transactions + const { txId } = (await client.sendRawTransaction(signedTransactions).do()) as { txId: string } + + AlgoKitConfig.logger.info(`Group transaction (${groupId}) sent with transaction ID ${txId}`) + + let confirmation: PendingTransactionResponse | undefined = undefined + if (!skipWaiting) { + confirmation = await waitForConfirmation(client, txId, 5) + } + + return { groupTransactionId: txId, confirmation } +} + +/** The response from the pending transaction API @see https://developer.algorand.org/docs/rest-apis/algod/v2/#get-v2transactionspendingtxid */ +export interface PendingTransactionResponse { + /** + * The application index if the transaction was found and it created an + * application. + */ + 'application-index'?: number + /** + * The number of the asset's unit that were transferred to the close-to address. + */ + 'asset-closing-amount'?: number + /** + * The asset index if the transaction was found and it created an asset. + */ + 'asset-index'?: number + /** + * Rewards in microalgos applied to the close remainder to account. + */ + 'close-rewards'?: number + /** + * Closing amount for the transaction. + */ + 'closing-amount'?: number + /** + * The round where this transaction was confirmed, if present. + */ + 'confirmed-round'?: number + /** + * (gd) Global state key/value changes for the application being executed by this + * transaction. + */ + 'global-state-delta'?: Record[] + /** + * Inner transactions produced by application execution. + */ + 'inner-txns'?: PendingTransactionResponse[] + /** + * (ld) Local state key/value changes for the application being executed by this + * transaction. + */ + 'local-state-delta'?: Record[] + /** + * (lg) Logs for the application being executed by this transaction. + */ + logs?: Uint8Array[] + /** Indicates that the transaction was kicked out of this node's transaction pool (and specifies why that happened). + * An empty string indicates the transaction wasn't kicked out of this node's txpool due to an error. */ + 'pool-error': string + /** + * Rewards in µALGOs applied to the receiver account. + */ + 'receiver-rewards'?: number + /** + * Rewards in µALGOs applied to the sender account. + */ + 'sender-rewards'?: number + /** + * The raw signed transaction. + */ + txn: EncodedSignedTransaction +} + +export interface EvalDelta { + action: number + bytes: string + uint: number +} + +/** + * Wait until the transaction is confirmed or rejected, or until `timeout` + * number of rounds have passed. + * + * @param client An algod client + * @param transactionId The transaction ID to wait for + * @param timeout Maximum number of rounds to wait + * + * @return Pending transaction information + * @throws Throws an error if the transaction is not confirmed or rejected in the next `timeout` rounds + */ +export const waitForConfirmation = async function ( + client: Algodv2, + transactionId: string, + timeout: number, +): Promise { + if (timeout < 0) { + throw new Error(`Invalid timeout, received ${timeout}, expected > 0`) + } + + // Get current round + const status = await client.status().do() + if (status === undefined) { + throw new Error('Unable to get node status') + } + + // Loop for up to `timeout` rounds looking for a confirmed transaction + const startRound = status['last-round'] + 1 + let currentRound = startRound + while (currentRound < startRound + timeout) { + const pendingInfo = (await client.pendingTransactionInformation(transactionId).do()) as PendingTransactionResponse + if (pendingInfo !== undefined) { + const confirmedRound = pendingInfo['confirmed-round'] + if (confirmedRound && confirmedRound > 0) { + return pendingInfo + } else { + const poolError = pendingInfo['pool-error'] + if (poolError != null && poolError.length > 0) { + // If there was a pool error, then the transaction has been rejected! + throw new Error(`Transaction ${transactionId} was rejected; pool error: ${poolError}`) + } + } + } + + await client.statusAfterBlock(currentRound).do() + currentRound++ + } + + throw new Error(`Transaction ${transactionId} not confirmed after ${timeout} rounds`) +} + +/** + * Limit the acceptable fee to a defined amount of µALGOs. + * This also sets the transaction to be flatFee to ensure the transaction only succeeds at + * the estimated rate. + * @param transaction The transaction to cap + * @param maxAcceptableFee The maximum acceptable fee to pay + */ +export function capTransactionFee(transaction: algosdk.Transaction, maxAcceptableFee: AlgoAmount) { + // If a flat fee hasn't already been defined + if (!transaction.flatFee) { + // Once a transaction has been constructed by algosdk, transaction.fee indicates what the total transaction fee + // Will be based on the current suggested fee-per-byte value. + if (transaction.fee > maxAcceptableFee.microAlgos) { + throw new Error( + `Cancelled transaction due to high network congestion fees. Algorand suggested fees would cause this transaction to cost ${transaction.fee} µALGOs. Cap for this transaction is ${maxAcceptableFee.microAlgos} µALGOs.`, + ) + } else if (transaction.fee > algosdk.ALGORAND_MIN_TX_FEE) { + AlgoKitConfig.logger.warn( + `Algorand network congestion fees are in effect. This transaction will incur a fee of ${transaction.fee} µALGOs.`, + ) + } + + // Now set the flat on the transaction. Otherwise the network may increase the fee above our cap and perform the transaction. + transaction.flatFee = true + } +} diff --git a/src/transfer.ts b/src/transfer.ts new file mode 100644 index 00000000..b19dc4b7 --- /dev/null +++ b/src/transfer.ts @@ -0,0 +1,44 @@ +import algosdk, { Account, Algodv2, LogicSigAccount } from 'algosdk' +import { AlgoAmount } from './algo-amount' +import { + encodeTransactionNote, + sendTransaction, + SendTransactionParams, + SendTransactionResult, + SigningAccount, + TransactionNote, +} from './transaction' + +interface AlgoTransferParams extends SendTransactionParams { + /** The account (with private key loaded) that will send the µALGOs */ + from: Account | SigningAccount | LogicSigAccount + /** The account address that will receive the ALGOs */ + to: string + /** The amount to send */ + amount: AlgoAmount + /** The (optional) transaction note */ + note?: TransactionNote +} + +/** + * Transfer ALGOs between two accounts. + * @param transfer The transfer definition + * @param client An algod client + * @returns The transaction object and optionally the confirmation if it was sent to the chain (`skipSending` is `false` or unset) + */ +export async function transferAlgos(transfer: AlgoTransferParams, client: Algodv2): Promise { + const { from, to, amount, note, ...sendConfig } = transfer + const params = await client.getTransactionParams().do() + + const transaction = algosdk.makePaymentTxnWithSuggestedParamsFromObject({ + from: 'addr' in from ? from.addr : from.address(), + to: to, + amount: amount.microAlgos, + note: note ? encodeTransactionNote(note) : undefined, + suggestedParams: params, + closeRemainderTo: undefined, + rekeyTo: undefined, + }) + + return sendTransaction(client, transaction, from, sendConfig) +} diff --git a/src/urlTokenBaseHTTPClient.ts b/src/urlTokenBaseHTTPClient.ts index 4a81a5cc..dad54fd0 100644 --- a/src/urlTokenBaseHTTPClient.ts +++ b/src/urlTokenBaseHTTPClient.ts @@ -1,8 +1,10 @@ // Copied from https://github.com/algorand/js-algorand-sdk/blob/e9635e9ffc9019994f0790ee4b8d9733c6590250/src/client/urlTokenBaseHTTPClient.ts // There was an error trying to reference the file from algosdk +// This is referenced from algo-http-client-with-retry.ts and extended to add retry logic to improve resilience +// todo: Find out why this can't be referenced from algosdk directly so we don't have to duplicate here +import { BaseHTTPClient, BaseHTTPClientError, BaseHTTPClientResponse, Query } from 'algosdk/dist/types/client/baseHTTPClient' import { Buffer } from 'buffer' import { fetch } from 'cross-fetch' -import { BaseHTTPClient, BaseHTTPClientResponse, BaseHTTPClientError, Query } from 'algosdk/dist/types/client/baseHTTPClient' export interface AlgodTokenHeader { 'X-Algo-API-Token': string @@ -39,6 +41,7 @@ export class URLTokenBaseHTTPClient implements BaseHTTPClient { private readonly baseURL: URL private readonly tokenHeader: TokenHeader + // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(tokenHeader: TokenHeader, baseServer: string, port?: string | number, private defaultHeaders: Record = {}) { // Append a trailing slash so we can use relative paths. Without the trailing // slash, the last path segment will be replaced by the relative path. See @@ -101,6 +104,7 @@ export class URLTokenBaseHTTPClient implements BaseHTTPClient { try { body = new Uint8Array(await res.arrayBuffer()) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const decoded: Record = JSON.parse(Buffer.from(body).toString()) if (decoded.message) { bodyErrorMessage = decoded.message diff --git a/tests/fixtures/localnet-fixture.ts b/tests/fixtures/localnet-fixture.ts new file mode 100644 index 00000000..8417d2de --- /dev/null +++ b/tests/fixtures/localnet-fixture.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach } from '@jest/globals' +import { Account, Algodv2, decodeSignedTransaction, Indexer } from 'algosdk' +import { AlgoAmount, getAlgoClient, getAlgoIndexerClient, getDefaultLocalNetConfig, getTestAccount } from '../../src' +import { lookupTransactionById } from '../../src/indexer-lookup' +import { TransactionLookupResult } from '../../src/indexer-type' + +export const localnetFixture = (testAccountFunding?: AlgoAmount) => { + const clientConfig = getDefaultLocalNetConfig('algod') + const client = getAlgoClient(clientConfig) + // todo: Sort out .env instead, cleaner and more flexible + process.env.ALGOD_SERVER = clientConfig.server + process.env.ALGOD_PORT = clientConfig.port?.toString() ?? '' + process.env.ALGOD_TOKEN = clientConfig.token?.toString() ?? '' + const indexer = getAlgoIndexerClient(getDefaultLocalNetConfig('indexer')) + let context: AlgorandTestAutomationContext + + beforeEach(async () => { + const transactionLogger = new TransactionLogger() + const txnLoggingAlgod = new Proxy(client, new TxnLoggingAlgodv2ProxyHandler(transactionLogger)) + const waitForIndexerTransaction = (txId: string) => runWhenIndexerCaughtUp(() => lookupTransactionById(indexer, txId)) + context = { + client: txnLoggingAlgod, + indexer: indexer, + testAccount: await getTestAccount({ client, initialFunds: testAccountFunding ?? AlgoAmount.Algos(10), suppressLog: true }), + transactionLogger: transactionLogger, + waitForIndexerTransaction, + } + }) + + return { + get context() { + return context + }, + } +} + +class TxnLoggingAlgodv2ProxyHandler implements ProxyHandler { + private transactionLogger: TransactionLogger + + constructor(transactionLogger: TransactionLogger) { + this.transactionLogger = transactionLogger + } + + get(target: Algodv2, property: string | symbol, receiver: any) { + if (property === 'sendRawTransaction') { + return (stxOrStxs: Uint8Array | Uint8Array[]) => { + this.transactionLogger.logRawTransaction(stxOrStxs) + return target[property].call(receiver, stxOrStxs) + } + } + return (target as any)[property] + } +} + +export interface AlgorandTestAutomationContext { + client: Algodv2 + indexer: Indexer + transactionLogger: TransactionLogger + testAccount: Account + waitForIndexerTransaction: (txId: string) => Promise +} + +export class TransactionLogger { + private _sentTransactionIds: string[] = [] + + get sentTransactionIds(): Readonly { + return this._sentTransactionIds + } + + logRawTransaction(signedTransactions: Uint8Array | Uint8Array[]) { + if (Array.isArray(signedTransactions)) { + for (const stxn of signedTransactions) { + const decoded = decodeSignedTransaction(stxn) + this._sentTransactionIds.push(decoded.txn.txID()) + } + } else { + const decoded = decodeSignedTransaction(signedTransactions) + this._sentTransactionIds.push(decoded.txn.txID()) + } + } + + async waitForIndexer(indexer: Indexer) { + await Promise.all(this._sentTransactionIds.map((txnId) => runWhenIndexerCaughtUp(() => indexer.lookupTransactionByID(txnId).do()))) + } +} + +async function runWhenIndexerCaughtUp(run: () => Promise): Promise { + let result: T | null = null + let ok = false + let tries = 0 + while (!ok) { + try { + result = await run() + ok = true + } catch (e: any) { + if (e?.status === 404) { + tries++ + if (tries > 20) { + throw e + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } else { + throw e + } + } + } + + return result as T +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 5417c7dc..096977c3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["src/**/*.spec.ts"] + "exclude": ["src/**/*.spec.ts", "tests/**/*.*"] } diff --git a/tsconfig.json b/tsconfig.json index a637a492..aaf87993 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist", "declaration": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] }