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/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/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/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_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 d1eb311cd..bbd713d6f 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_transact::Address; +use algokit_http_client::HttpError; +use algokit_transact::{Address, constants::MAX_TX_GROUP_SIZE}; use snafu::Snafu; -use std::{str::FromStr, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + str::FromStr, + sync::Arc, +}; use crate::transactions::{ AssetOptInParams, AssetOptOutParams, ComposerError, TransactionComposer, @@ -93,7 +101,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 +111,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 +121,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 +142,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, } } @@ -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(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) .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(AssetManagerError::AlgodClientError { source: error }) + }) } pub async fn bulk_opt_in( @@ -203,36 +218,43 @@ impl AssetManager { return Ok(Vec::new()); } - let mut composer = (self.new_composer)(None); + // 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(); - // 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() - }; + let mut bulk_results = Vec::with_capacity(unique_ids.len()); - composer - .add_asset_opt_in(opt_in_params) - .map_err(|e| AssetManagerError::ComposerError { source: e })?; - } + for asset_chunk in unique_ids.chunks(MAX_TX_GROUP_SIZE) { + let mut composer = (self.new_composer)(None); - // Send the transaction group - let composer_result = composer - .send(Default::default()) - .await - .map_err(|e| AssetManagerError::ComposerError { source: e })?; + for &asset_id in asset_chunk { + let opt_in_params = AssetOptInParams { + sender: account.clone(), + asset_id, + ..Default::default() + }; - // 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(); + 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 })?; + + 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) } @@ -247,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 @@ -269,47 +299,142 @@ impl AssetManager { } // Fetch asset information to get creators - let mut asset_creators = Vec::new(); - 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.push(creator); + asset_creators.insert(asset_id, creator); } - let mut composer = (self.new_composer)(None); + let asset_creator_pairs: Vec<(u64, Address)> = unique_ids + .iter() + .map(|&asset_id| { + let creator = asset_creators + .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); - // 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_id, creator) in asset_chunk.iter() { + let opt_out_params = AssetOptOutParams { + sender: account.clone(), + asset_id: *asset_id, + close_remainder_to: Some(creator.clone()), + ..Default::default() + }; - composer - .add_asset_opt_out(opt_out_params) + 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 })?; + + bulk_results.extend(asset_chunk.iter().zip(composer_result.results.iter()).map( + |((asset_id, _), result)| BulkAssetOptInOutResult { + asset_id: *asset_id, + transaction_id: result.transaction_id.clone(), + }, + )); } - // Send the transaction group - let composer_result = composer - .send(Default::default()) - .await - .map_err(|e| AssetManagerError::ComposerError { source: e })?; + Ok(bulk_results) + } +} - // 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(); +fn map_get_asset_by_id_error(error: &AlgodError, asset_id: u64) -> Option { + match error { + 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 }) + } + _ => http_error_message(source).and_then(|message| { + if message.contains("status 404") { + Some(AssetManagerError::AssetNotFound { asset_id }) + } else { + None + } + }), + } + } + _ => None, + } +} - Ok(bulk_results) +fn map_account_asset_information_error( + error: &AlgodError, + address: &str, + asset_id: u64, +) -> Option { + match error { + 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(), + }) + } + _ => 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()), + HttpError::StatusError { message, .. } => Some(message.as_str()), } } 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/crates/algokit_utils/tests/clients/asset_manager.rs b/crates/algokit_utils/tests/clients/asset_manager.rs index 4a4997542..2cd3f8e91 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}, @@ -38,11 +38,15 @@ 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 +97,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(()) @@ -185,6 +193,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 +305,36 @@ 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(); + + asset_manager.bulk_opt_in(&test_address, &asset_ids).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/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/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/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, 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_abi/package.json b/packages/typescript/algokit_abi/package.json index a56fab036..78934b3c7 100644 --- a/packages/typescript/algokit_abi/package.json +++ b/packages/typescript/algokit_abi/package.json @@ -33,7 +33,8 @@ "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 d59438975..7a456b1a4 100644 --- a/packages/typescript/algokit_common/package.json +++ b/packages/typescript/algokit_common/package.json @@ -28,7 +28,8 @@ "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", @@ -42,5 +43,10 @@ }, "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" + } } 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_transact/package.json b/packages/typescript/algokit_transact/package.json index 4f0f66cae..30da9b618 100644 --- a/packages/typescript/algokit_transact/package.json +++ b/packages/typescript/algokit_transact/package.json @@ -28,7 +28,8 @@ "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 35d86709d..7a4479e18 100644 --- a/packages/typescript/algokit_utils/package.json +++ b/packages/typescript/algokit_utils/package.json @@ -28,7 +28,8 @@ "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/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/app-manager.ts b/packages/typescript/algokit_utils/src/clients/app-manager.ts index bd44206ef..ea9078b9d 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,9 +140,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: 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), @@ -170,10 +170,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: bytesToBase64(nameRaw), + name: bytesToUtf8(nameRaw), } }) } @@ -182,12 +183,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:${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 { @@ -198,42 +199,22 @@ export class AppManager { return values } - private static ensureDecodedBytes(bytes: Uint8Array): Uint8Array { - try { - const str = Buffer.from(bytes).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))) { - 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 = new Uint8Array(Buffer.from(stateVal.key, 'base64')) + const keyRaw = toBytes(stateVal.key) const keyBase64 = stateVal.key - const keyString = Buffer.from(keyRaw).toString('base64') // 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 = ensureDecodedBytes(new Uint8Array(stateVal.value.bytes)) + const valueBase64 = 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 = { @@ -243,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/asset-manager.ts b/packages/typescript/algokit_utils/src/clients/asset-manager.ts index f5f95a80e..8b3081275 100644 --- a/packages/typescript/algokit_utils/src/clients/asset-manager.ts +++ b/packages/typescript/algokit_utils/src/clients/asset-manager.ts @@ -1,7 +1,8 @@ -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' +import { MAX_TX_GROUP_SIZE } from '@algorandfoundation/algokit-common' +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 { @@ -100,7 +101,7 @@ export interface AssetInformation { * * Max size is 8 bytes. */ - unitNameB64?: Uint8Array + unitNameAsBytes?: Uint8Array /** The optional name of the asset. * @@ -112,7 +113,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). * @@ -124,7 +125,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. * @@ -135,8 +136,8 @@ export interface AssetInformation { /** 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 @@ -147,26 +148,32 @@ export class AssetManager { * 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, + unitNameAsBytes: asset.params.unitNameB64, + assetName: asset.params.name, + assetNameAsBytes: asset.params.nameB64, + url: asset.params.url, + urlAsBytes: asset.params.urlB64, + metadataHash: asset.params.metadataHash, + } + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw createError(`Asset not found: ${assetId}`, error) + } + throw createError(`Failed to fetch asset information for asset ${assetId}`, error) } } @@ -175,7 +182,19 @@ export class AssetManager { * Access asset holding via `account_info.asset_holding` and asset params via `account_info.asset_params`. */ async getAccountInformation(sender: string, assetId: bigint): Promise { - return await this.algodClient.accountAssetInformation(sender, Number(assetId)) + try { + return await this.algodClient.accountAssetInformation(sender, assetId, { format: 'json' }) + } catch (error) { + if (error instanceof ApiError) { + if (error.status === 404) { + throw createError(`Account ${sender} is not opted into asset ${assetId}`, error) + } + if (error.status === 400) { + throw createError(`Account not found: ${sender}`, error) + } + } + throw createError(`Failed to fetch account asset information for account ${sender} and asset ${assetId}`, error) + } } async bulkOptIn(account: string, assetIds: bigint[]): Promise { @@ -183,26 +202,44 @@ export class AssetManager { return [] } - const composer = this.newComposer() + // Ignore duplicate asset IDs while preserving input order + const uniqueIds = [...new Set(assetIds)] + + const results: BulkAssetOptInOutResult[] = [] - // Add asset opt-in transactions for each asset - for (const assetId of assetIds) { - const optInParams: AssetOptInParams = { - sender: account, - assetId, + for (const batch of chunkArray(uniqueIds, MAX_TX_GROUP_SIZE)) { + const composer = this.newComposer() + + for (const assetId of batch) { + const params: AssetOptInParams = { + sender: account, + assetId, + } + + try { + composer.addAssetOptIn(params) + } catch (error) { + throw createError(`Failed to add opt-in for asset ${assetId}`, error) + } } - composer.addAssetOptIn(optInParams) - } + try { + const result = await composer.send() - // Send the transaction group - const composerResults = await composer.send() + if (result.results.length !== batch.length) { + throw new Error(`Composer returned an unexpected number of results (expected ${batch.length}, actual ${result.results.length})`) + } - // Map transaction IDs back to assets - const results: BulkAssetOptInOutResult[] = assetIds.map((assetId, index) => ({ - assetId, - transactionId: composerResults.transactionIds[index], - })) + batch.forEach((assetId, index) => { + results.push({ + assetId, + transactionId: result.results[index].transactionId, + }) + }) + } catch (error) { + throw createError('Failed to submit opt-in transactions', error) + } + } return results } @@ -212,53 +249,67 @@ 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 we need to check balances, verify they are all zero if (shouldCheckBalance) { - for (const assetId of assetIds) { + for (const assetId of uniqueIds) { 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}`) + + const balance = accountInfo.assetHolding?.amount ?? 0n + if (balance > 0n) { + throw new Error(`Account ${account} has non-zero balance (${balance}) for asset ${assetId}`) } } } - // 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}`) - } + // 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) } - const composer = this.newComposer() + // Prepare stable pairs to preserve input order + const assetCreatorPairs = uniqueIds.map((assetId) => [assetId, creatorCache.get(assetId)!] as const) - // Add asset opt-out transactions for each asset - assetIds.forEach((assetId, index) => { - const creator = assetCreators[index] + for (const batch of chunkArray(assetCreatorPairs, MAX_TX_GROUP_SIZE)) { + const composer = this.newComposer() - const optOutParams: AssetOptOutParams = { - sender: account, - assetId, - closeRemainderTo: creator, + for (const [assetId, creator] of batch) { + const params: AssetOptOutParams = { + sender: account, + assetId, + closeRemainderTo: creator, + } + + try { + composer.addAssetOptOut(params) + } catch (error) { + throw createError(`Failed to add opt-out for asset ${assetId}`, error) + } } - composer.addAssetOptOut(optOutParams) - }) + try { + const result = await composer.send() - // Send the transaction group - const composerResults = await composer.send() + if (result.results.length !== batch.length) { + throw new Error(`Composer returned an unexpected number of results (expected ${batch.length}, actual ${result.results.length})`) + } - // Map transaction IDs back to assets - const results: BulkAssetOptInOutResult[] = assetIds.map((assetId, index) => ({ - assetId, - transactionId: composerResults.transactionIds[index], - })) + batch.forEach(([assetId], index) => { + results.push({ + assetId, + transactionId: result.results[index].transactionId, + }) + }) + } catch (error) { + throw createError('Failed to submit opt-out transactions', error) + } + } return results } 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..d03b72e9c --- /dev/null +++ b/packages/typescript/algokit_utils/src/clients/client-manager.ts @@ -0,0 +1,488 @@ +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' + +import { + AlgoClientConfig, + AlgoConfig, + AlgorandService, + NetworkDetails, + TokenHeader, + genesisIdIsLocalNet, + genesisIdIsMainnet, + genesisIdIsTestnet, +} from './network-client' + +export interface ClientManagerClients { + algod: AlgodClient + indexer?: IndexerClient + kmd?: KmdClient +} + +interface NetworkCache { + value?: NetworkDetails + promise?: Promise +} + +type HttpClientFactoryResult = { + clientConfig: HttpClientConfig + request?: BaseHttpRequest +} + +type HttpClientFactory = (config: AlgoClientConfig, defaultHeaderName: string) => HttpClientFactoryResult + +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, + } + + 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 + } + + /** + * 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 { + 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, + } + } + + /** + * 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 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 { + 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 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') + * ``` + */ + public static getDefaultLocalNetConfig(configOrPort: AlgorandService | number): AlgoClientConfig { + return { + server: `http://localhost`, + port: + configOrPort === AlgorandService.Algod + ? 4001 + : configOrPort === AlgorandService.Indexer + ? 8980 + : configOrPort === AlgorandService.Kmd + ? 4002 + : configOrPort, + token: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + } + } + + /** + * Creates an Algod client for the given configuration. + */ + static getAlgodClient(config: AlgoClientConfig): AlgodClient { + 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, 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, request } = this.createHttpClientComponents(config, 'X-KMD-API-Token') + return new KmdClient(clientConfig, request) + } + + /** + * 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: genesisIdIsTestnet(genesisId), + isMainnet: genesisIdIsMainnet(genesisId), + 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 createHttpClientComponents(config: AlgoClientConfig, defaultHeaderName: string): HttpClientFactoryResult { + if (this.httpClientFactory) { + return this.httpClientFactory(config, defaultHeaderName) + } + const clientConfig = this.buildHttpClientConfig(config, defaultHeaderName) + return { + clientConfig, + request: new FetchHttpRequest(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 + } + + 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..b27b1898d 100644 --- a/packages/typescript/algokit_utils/src/clients/network-client.ts +++ b/packages/typescript/algokit_utils/src/clients/network-client.ts @@ -1,12 +1,36 @@ -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', +} + +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. */ 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 +44,33 @@ 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) { - return genesisId === 'devnet-v1' || genesisId === 'sandnet-v1' || genesisId === 'dockernet-v1' +export function genesisIdIsLocalNet(genesisId: string): boolean { + 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) } diff --git a/packages/typescript/algokit_utils/src/index.ts b/packages/typescript/algokit_utils/src/index.ts index aa1fca666..45e0271d9 100644 --- a/packages/typescript/algokit_utils/src/index.ts +++ b/packages/typescript/algokit_utils/src/index.ts @@ -1,3 +1,8 @@ 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 './testing/indexer' export * from '@algorandfoundation/algokit-transact' 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/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..409bd9c23 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,42 @@ 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) { + // Transaction not yet in pool, wait for next block + await algodClient.waitForBlock(currentRound) + currentRound++ + continue + } else { + throw e + } + } + + 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 a58fb73ed..205243bcd 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,16 +23,18 @@ import { getTransactionId, groupTransactions, } from '@algorandfoundation/algokit-transact' -import { genesisIdIsLocalNet } from '../clients/network-client' import { - ApplicationLocalReference, - AssetHoldingReference, - BoxReference, - PendingTransactionResponse, - SimulateResponse, - SimulateUnnamedResourcesAccessed, - TransactionParams, -} from '../temp' + AlgodClient, + 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, @@ -93,6 +94,7 @@ import { TransactionSigner, TransactionWithSigner, TransactionWithSignerComposerTransaction, + waitForConfirmation, } from './common' import { FeeDelta, FeePriority } from './fee-coverage' import { @@ -174,7 +176,7 @@ export type SendTransactionComposerResults = { } export type TransactionComposerParams = { - algodClient: any + algodClient: AlgodClient signerGetter: SignerGetter composerConfig?: TransactionComposerConfig } @@ -185,7 +187,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 +350,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 +467,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: ${(ctxn as { type: ComposerTransactionType }).type}`) } if (calculateFee) { @@ -668,7 +668,7 @@ export class TransactionComposer { }) satisfies SignedTransaction, ) - const simulateRequest = { + const simulateRequest: SimulateRequest = { txnGroups: [ { txns: signedTransactions, @@ -679,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 @@ -802,7 +802,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)) @@ -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,37 +837,6 @@ export class TransactionComposer { return this.transactions.length } - private async waitForConfirmation(txId: string, maxRoundsToWait: number): Promise { - const status = await this.algodClient.status().do() - const startRound = status.lastRound + 1 - 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) { - // TODO: Handle the 404 correctly once algod client is build - if (e instanceof Error && e.message.includes('404')) { - currentRound++ - continue - } - } - await this.algodClient.statusAfterBlock(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/transactions/sender.ts b/packages/typescript/algokit_utils/src/transactions/sender.ts index 466de60bf..268a6cc58 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 <= 0n) { + 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 <= 0n) { + 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 <= 0n) { + throw new Error('App creation confirmation missing appId') } return { ...baseResult, - appId: applicationIndex, + appId: appId, } }, sendParams, 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/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..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 './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 b148c8415..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 './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 865a71d73..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 './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 new file mode 100644 index 000000000..57eb347b8 --- /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 { createAlgorandTestContext, createFundedAccount, createTestAsset, transferAsset } from '../fixtures' + +const TEST_TIMEOUT = 120_000 + +describe.sequential('AssetManager integration', () => { + it( + 'retrieves asset information by id', + async () => { + const context = await createAlgorandTestContext() + 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 createAlgorandTestContext() + + await expect(context.assetManager.getById(9_999_999_999n)).rejects.toMatchObject({ + message: 'Asset not found: 9999999999', + }) + }, + TEST_TIMEOUT, + ) + + it( + 'retrieves account holdings for opted-in creator', + async () => { + const context = await createAlgorandTestContext() + 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 createAlgorandTestContext() + const { assetId } = await createTestAsset(context) + const account = await createFundedAccount(context) + + await expect(context.assetManager.getAccountInformation(account.address, assetId)).rejects.toMatchObject({ + message: expect.stringContaining('is not opted into asset'), + }) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt in opts into each requested asset', + async () => { + 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) + + 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 createAlgorandTestContext() + const assetCount = MAX_TX_GROUP_SIZE + 3 + const assetIds: bigint[] = [] + for (let i = 0; i < assetCount; i++) { + assetIds.push((await createTestAsset(context)).assetId) + } + 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 createAlgorandTestContext() + 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 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) + + 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({ + message: expect.stringContaining('is not opted into asset'), + }) + } + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out splits batches appropriately', + async () => { + 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)).assetId) + } + 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 createAlgorandTestContext() + 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 createAlgorandTestContext() + 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({ + message: expect.stringContaining('has non-zero balance'), + }) + }, + TEST_TIMEOUT, + ) + + it( + 'bulk opt out can override the balance check and close out remaining balance', + async () => { + const context = await createAlgorandTestContext() + 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({ + 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 new file mode 100644 index 000000000..619daeea3 --- /dev/null +++ b/packages/typescript/algokit_utils/tests/clients/client-manager.test.ts @@ -0,0 +1,58 @@ +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) + + 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/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) + }) +}) diff --git a/packages/typescript/algokit_utils/tests/fixtures.ts b/packages/typescript/algokit_utils/tests/fixtures.ts new file mode 100644 index 000000000..73a2abe25 --- /dev/null +++ b/packages/typescript/algokit_utils/tests/fixtures.ts @@ -0,0 +1,443 @@ +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 publicKey = secretKey.slice(32) + const address = addressFromPublicKey(publicKey) + return { address, 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 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/helpers.ts b/packages/typescript/algokit_utils/tests/indexer/helpers.ts deleted file mode 100644 index b2084f1a8..000000000 --- a/packages/typescript/algokit_utils/tests/indexer/helpers.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe } from 'vitest' -import algosdk from 'algosdk' -import * as ed from '@noble/ed25519' -import { Buffer } from 'node:buffer' - -import { - type Transaction, - type SignedTransaction, - TransactionType, - OnApplicationComplete, - encodeTransaction, - encodeSignedTransaction, - getTransactionId, -} from '@algorandfoundation/algokit-transact' -import { IndexerClient } from '@algorandfoundation/indexer-client' -import { runWhenIndexerCaughtUp } from '../../src/testing/indexer' - -export interface IndexerTestConfig { - indexerBaseUrl: string - indexerApiToken?: string -} - -export interface CreatedAssetInfo { - assetId: bigint - txId: string -} - -export interface CreatedAppInfo { - appId: bigint - txId: string -} - -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) - - // 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 generated = await kmd.generateKey(handle.wallet_handle_token) - address = generated.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) - } -} - -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 } -} - -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 server = `${url.protocol}//${url.hostname}` - const port = Number(url.port || 4001) - return new algosdk.Algodv2(algodToken, server, port) -} - -function decodeGenesisHash(genesisHash: string | Uint8Array): Uint8Array { - if (genesisHash instanceof Uint8Array) { - return new Uint8Array(genesisHash) - } - 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) - const txId = getTransactionId(transaction) - await algod.sendRawTransaction(raw).do() - await algosdk.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 firstValid = BigInt(sp.firstValid ?? sp.lastValid) - 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).do()).assetIndex 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.getTransactionParams().do() - - 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() - 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 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).do()).applicationIndex - 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 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/tests/indexer/searchApplications.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts index 4a9ae6651..47343ef0b 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchApplications.test.ts @@ -1,10 +1,12 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyApp, getIndexerEnv, waitForIndexerTransaction } from './helpers' +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 }) diff --git a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts index 65eee0a62..0d5305936 100644 --- a/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts +++ b/packages/typescript/algokit_utils/tests/indexer/searchTransactions.test.ts @@ -1,14 +1,16 @@ import { expect, it, describe } from 'vitest' import { IndexerClient } from '@algorandfoundation/indexer-client' -import { createDummyAsset, getIndexerEnv, waitForIndexerTransaction } from './helpers' +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 waitForIndexerTransaction(client, txId) + await waitForIndexerTransaction(client, txnId) const res = await client.searchForTransactions() expect(res).toHaveProperty('transactions') 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/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/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", 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 } } diff --git a/packages/typescript/package-lock.json b/packages/typescript/package-lock.json index e4f666a8e..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", @@ -113,7 +112,12 @@ "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" + }, "engines": { "node": ">=20.0" } @@ -127,6 +131,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 +161,7 @@ "algokit_transact/dist": { "name": "@algorandfoundation/algokit-transact", "version": "0.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=20.0" @@ -2327,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", @@ -3614,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", @@ -5120,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", @@ -5436,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 bf8a26c9b..685829d9c 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": { @@ -36,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",