diff --git a/docs/feature-plans/stacks-account-do.md b/docs/feature-plans/stacks-account-do.md new file mode 100644 index 0000000..eb2aa21 --- /dev/null +++ b/docs/feature-plans/stacks-account-do.md @@ -0,0 +1,81 @@ +# Feature Plan: Stacks Account Durable Object (StacksAccountDO) + +This document outlines the plan to create a new Durable Object for managing Stacks account-specific data, starting with the account nonce. + +## Objective + +Create a new Durable Object, `StacksAccountDO`, where each instance is uniquely identified by a Stacks address. This object will manage and cache the nonce for that specific address using the Durable Object's private storage. The API will be extensible for future account-related data. + +## API Design + +| Method | Endpoint | Description | +| :----- | :---------------------------------------- | :----------------------------------------------------------------------------- | +| `GET` | `/stacks-account/{address}/nonce` | Gets the cached nonce from DO storage. Fetches if missing. | +| `GET` | `/stacks-account/{address}/nonce?bustCache=true` | Forces a fresh fetch from the blockchain and updates the value in DO storage. | +| `POST` | `/stacks-account/{address}/nonce/sync` | Explicitly fetches the latest nonce from the blockchain and updates storage. | +| `POST` | `/stacks-account/{address}/nonce/update` | Manually updates the nonce. Expects a JSON body: `{ "nonce": 123 }`. | + +## Implementation Tasks + +### Phase 1: Core Logic & Services + +- [x] **Create `StacksAccountDataService`**: A new service to handle rate-limiting and retries for fetching account data from the Hiro API. This prevents direct calls from the DO to the external API, ensuring we don't exceed rate limits. +- [x] **Update `StacksApiService`**: Add a `getAccountNonce` method that uses `fetch` to call the Hiro `/extended/v1/addresses/{principal}/nonces` endpoint. +- [x] **Create `StacksAccountDO`**: The main Durable Object class. + - [x] Use the Stacks address from `ctx.id` as its identifier. + - [x] Use `ctx.storage` for storing the nonce. + - [x] Implement the `fetch` handler to route requests to the correct methods. + - [x] Implement `getNonce`, `syncNonce`, and `updateNonce` logic. + - [x] Use `StacksAccountDataService` for all external data fetching. + +### Phase 2: Configuration & Routing + +- [x] **Update `wrangler.toml`**: + - [x] Add a new migration for the `StacksAccountDO` class. + - [x] Add the `STACKS_ACCOUNT_DO` binding to all environments (`preview`, `staging`, `production`). +- [x] **Update `worker-configuration.d.ts`**: Add `STACKS_ACCOUNT_DO` to the `Env` interface. +- [x] **Update `src/config.ts`**: Add `/stacks-account` to the `SUPPORTED_SERVICES` list. +- [x] **Update `src/index.ts`**: + - [x] Import and export the `StacksAccountDO` class. + - [x] Add routing logic to forward requests starting with `/stacks-account/{address}` to the correct DO instance. + +### Phase 3: Verification + +- [ ] Deploy the changes to a preview environment. +- [ ] Test all API endpoints using an HTTP client like `curl`. + - [ ] Verify initial fetch (cache miss). + - [ ] Verify subsequent fetch (cache hit). + - [ ] Verify `bustCache=true` functionality. + - [ ] Verify `/sync` endpoint. + - [ ] Verify `/update` endpoint. + +### Testing Examples (`curl`) + +Replace `` with the deployment URL and `
` with a valid Stacks address. + +1. **Get Nonce (Cache Miss/Hit):** + ```bash + curl /stacks-account/
/nonce + ``` + +2. **Force Refresh with `bustCache`:** + ```bash + curl /stacks-account/
/nonce?bustCache=true + ``` + +3. **Force Refresh with `sync`:** + ```bash + curl -X POST /stacks-account/
/nonce/sync + ``` + +4. **Manually Update Nonce:** + ```bash + curl -X POST -H "Content-Type: application/json" \ + -d '{"nonce": 999}' \ + /stacks-account/
/nonce/update + ``` + +5. **Test Invalid Address:** + ```bash + curl /stacks-account/INVALID-ADDRESS/nonce + ``` diff --git a/src/config.ts b/src/config.ts index 22b97bb..91d8220 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,7 +51,7 @@ export class AppConfig { return { // supported services for API caching // each entry is a durable object that handles requests - SUPPORTED_SERVICES: ['/bns', '/hiro-api', '/stx-city', '/supabase', '/contract-calls'], + SUPPORTED_SERVICES: ['/bns', '/hiro-api', '/stx-city', '/supabase', '/contract-calls', '/stacks-account'], // VALUES BELOW CAN BE OVERRIDDEN BY DURABLE OBJECTS // default cache TTL used for KV CACHE_TTL: 900, // 15 minutes diff --git a/src/durable-objects/stacks-account-do.ts b/src/durable-objects/stacks-account-do.ts new file mode 100644 index 0000000..8564592 --- /dev/null +++ b/src/durable-objects/stacks-account-do.ts @@ -0,0 +1,106 @@ +import { DurableObject } from 'cloudflare:workers'; +import { Env } from '../../worker-configuration'; +import { AppConfig } from '../config'; +import { StacksAccountDataService } from '../services/stacks-account-data-service'; +import { handleRequest } from '../utils/request-handler-util'; +import { ApiError } from '../utils/api-error-util'; +import { ErrorCode } from '../utils/error-catalog-util'; +import { validateStacksAddress } from '@stacks/transactions'; + +export class StacksAccountDO extends DurableObject { + private accountDataService: StacksAccountDataService; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.env = env; + + const config = AppConfig.getInstance(env).getConfig(); + const hiroConfig = config.HIRO_API_RATE_LIMIT; + + // Use Hiro API rate limits since we're hitting their endpoints + this.accountDataService = new StacksAccountDataService( + env, + hiroConfig.MAX_REQUESTS_PER_INTERVAL, + hiroConfig.INTERVAL_MS, + config.MAX_RETRIES, + config.RETRY_DELAY + ); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const path = url.pathname; + const address = path.split('/')[2]; + // e.g., /stacks-account/{address}/nonce -> /nonce + const endpoint = url.pathname.replace(`/stacks-account/${address}`, '') || '/'; + const method = request.method; + + return handleRequest( + async () => { + if (!validateStacksAddress(address)) { + throw new ApiError(ErrorCode.INVALID_CONTRACT_ADDRESS, { address: address }); + } + + // Route to different functions based on the endpoint + if (endpoint.startsWith('/nonce')) { + return this.handleNonceRequest(request, endpoint, address); + } + + // Default response for the root of the DO + if (endpoint === '/') { + return { message: `StacksAccountDO for ${address}. Supported endpoints: /nonce` }; + } + + throw new ApiError(ErrorCode.NOT_FOUND, { resource: endpoint }); + }, + this.env, + { path: url.pathname, method } + ); + } + + private async handleNonceRequest(request: Request, endpoint: string, address: string): Promise<{ nonce: number }> { + const url = new URL(request.url); + const method = request.method; + + if (endpoint === '/nonce' && method === 'GET') { + const bustCache = url.searchParams.get('bustCache') === 'true'; + return this.getNonce(address, bustCache); + } + + if (endpoint === '/nonce/sync' && method === 'POST') { + return this.syncNonce(address); + } + + if (endpoint === '/nonce/update' && method === 'POST') { + const { nonce } = (await request.json()) as { nonce: number }; + if (typeof nonce !== 'number') { + throw new ApiError(ErrorCode.INVALID_ARGUMENTS, { reason: 'Nonce must be a number' }); + } + return this.updateNonce(nonce); + } + + throw new ApiError(ErrorCode.INVALID_REQUEST, { reason: `Method ${method} not supported for ${endpoint}` }); + } + + private async getNonce(address: string, bustCache: boolean): Promise<{ nonce: number }> { + if (!bustCache) { + const storedNonce = await this.ctx.storage.get('nonce'); + if (storedNonce !== undefined) { + return { nonce: storedNonce }; + } + } + // If cache is busted or nonce is not in storage, sync it + return this.syncNonce(address); + } + + private async syncNonce(address: string): Promise<{ nonce: number }> { + const nonce = await this.accountDataService.fetchNonce(address); + await this.ctx.storage.put('nonce', nonce); + return { nonce }; + } + + private async updateNonce(nonce: number): Promise<{ nonce: number }> { + await this.ctx.storage.put('nonce', nonce); + return { nonce }; + } +} diff --git a/src/index.ts b/src/index.ts index 676e1fa..982847c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,13 +5,14 @@ import { HiroApiDO } from './durable-objects/hiro-api-do'; import { StxCityDO } from './durable-objects/stx-city-do'; import { SupabaseDO } from './durable-objects/supabase-do'; import { ContractCallsDO } from './durable-objects/contract-calls-do'; +import { StacksAccountDO } from './durable-objects/stacks-account-do'; import { corsHeaders, createErrorResponse, createSuccessResponse } from './utils/requests-responses-util'; import { ApiError } from './utils/api-error-util'; import { ErrorCode } from './utils/error-catalog-util'; import { Logger } from './utils/logger-util'; // export the Durable Object classes we're using -export { BnsApiDO, HiroApiDO, StxCityDO, SupabaseDO, ContractCallsDO }; +export { BnsApiDO, HiroApiDO, StxCityDO, SupabaseDO, ContractCallsDO, StacksAccountDO }; export default { /** @@ -90,6 +91,16 @@ export default { let stub = env.CONTRACT_CALLS_DO.get(id); // get the stub for communication return await stub.fetch(request); // forward the request to the Durable Object } + + if (path.startsWith('/stacks-account')) { + const address = path.split('/')[2]; + if (!address) { + throw new ApiError(ErrorCode.INVALID_REQUEST, { reason: 'Missing Stacks address in path' }); + } + const id: DurableObjectId = env.STACKS_ACCOUNT_DO.idFromName(address); + const stub = env.STACKS_ACCOUNT_DO.get(id); + return await stub.fetch(request); + } } catch (error) { // Log errors from Durable Objects const duration = Date.now() - startTime; diff --git a/src/services/stacks-account-data-service.ts b/src/services/stacks-account-data-service.ts new file mode 100644 index 0000000..8c1e623 --- /dev/null +++ b/src/services/stacks-account-data-service.ts @@ -0,0 +1,44 @@ +import { StacksNetworkName } from '@stacks/network'; +import { Env } from '../../worker-configuration'; +import { AppConfig } from '../config'; +import { StacksApiService } from './stacks-api-service'; +import { RequestQueue } from './request-queue-service'; +import { getNetworkByPrincipal } from '../utils/stacks-network-util'; + +/** + * Service for fetching data about Stacks accounts. + * Handles rate limiting and retries for account-related API calls. + */ +export class StacksAccountDataService { + private readonly stacksApiService: StacksApiService; + private readonly requestQueue: RequestQueue; // Queue for nonce (number) requests + + constructor( + private readonly env: Env, + maxRequestsPerInterval: number, + intervalMs: number, + maxRetries: number, + retryDelay: number + ) { + const config = AppConfig.getInstance(env).getConfig(); + const requestTimeout = config?.TIMEOUTS?.STACKS_API || 5000; + + this.stacksApiService = new StacksApiService(env); + this.requestQueue = new RequestQueue(maxRequestsPerInterval, intervalMs, maxRetries, retryDelay, env, requestTimeout); + } + + /** + * Fetches the nonce for a Stacks account with rate limiting. + * + * @param address - The Stacks principal address. + * @returns A promise that resolves to the account's nonce. + */ + public async fetchNonce(address: string): Promise { + const network = getNetworkByPrincipal(address) as StacksNetworkName; + + // Queue the request to respect rate limits + return this.requestQueue.enqueue(async () => { + return this.stacksApiService.getAccountNonce(address, network); + }); + } +} diff --git a/src/services/stacks-api-service.ts b/src/services/stacks-api-service.ts index 73cf692..cac6b28 100644 --- a/src/services/stacks-api-service.ts +++ b/src/services/stacks-api-service.ts @@ -75,7 +75,9 @@ export class StacksApiService { functionArgs, senderAddress, network, - fetchFn: customFetchFn, // Use the API key middleware if available + client: { + fetch: customFetchFn, // Use the API key middleware if available + }, }), this.timeoutMs, `Contract call to ${contractAddress}.${contractName}::${functionName} timed out` @@ -150,4 +152,58 @@ export class StacksApiService { }); } } + + /** + * Fetches the current nonce for a given Stacks address using the Hiro API. + * + * @param address - The Stacks principal address. + * @param network - The Stacks network to use ('mainnet' or 'testnet'). + * @returns A promise that resolves to the account's next possible nonce. + */ + async getAccountNonce(address: string, network: StacksNetworkName): Promise { + const logger = Logger.getInstance(this.env); + const startTime = Date.now(); + const requestId = logger.info(`Fetching nonce for address: ${address} on ${network}`); + + const url = `https://api.${network}.hiro.so/extended/v1/address/${address}/nonces`; + const headers: HeadersInit = {}; + + if (this.env?.HIRO_API_KEY) { + headers['x-hiro-api-key'] = this.env.HIRO_API_KEY; + } + + try { + const response = await withTimeout(fetch(url, { headers }), this.timeoutMs, `Nonce lookup for ${address} timed out`); + + if (!response.ok) { + const errorText = await response.text(); + throw new ApiError(ErrorCode.UPSTREAM_API_ERROR, { + message: `Failed to fetch nonce: ${response.status} ${response.statusText}`, + details: errorText, + address, + }); + } + + const data = (await response.json()) as { possible_next_nonce: number }; + const duration = Date.now() - startTime; + logger.debug(`Nonce fetch completed for ${address}`, { requestId, duration }); + + return data.possible_next_nonce; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Failed to fetch nonce for ${address}`, error instanceof Error ? error : new Error(String(error)), { + requestId, + duration, + }); + + // Re-throw as a consistent ApiError if it's not one already + if (error instanceof ApiError) { + throw error; + } + throw new ApiError(ErrorCode.UPSTREAM_API_ERROR, { + message: `Failed to fetch nonce: ${error instanceof Error ? error.message : String(error)}`, + address, + }); + } + } } diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 2163063..c0ac750 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -10,4 +10,5 @@ export interface Env { STX_CITY_DO: DurableObjectNamespace; SUPABASE_DO: DurableObjectNamespace; CONTRACT_CALLS_DO: DurableObjectNamespace; + STACKS_ACCOUNT_DO: DurableObjectNamespace; } diff --git a/wrangler.toml b/wrangler.toml index 8adda6d..1cacd63 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,90 +8,125 @@ account_id = "96280594e2b905d4dc40b3c744149710" [observability] enabled = true -# durable object migrations -[[migrations]] -tag = "20250102" -new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] - -[[migrations]] -tag = "20250323" -new_classes = ["ContractCallsDO"] - +## NOTE: everything organized by environment [env.preview] -routes = [] +# preview: routes -# fixing a deploy error from old code -[[env.preview.migrations]] -tag = "20250417" +# auto-generated by Wrangler +routes = [] -[[env.preview.migrations]] -tag = "20250530" -deleted_classes = ["ChainhooksDO"] +# preview: kv namespaces [[env.preview.kv_namespaces]] binding = "AIBTCDEV_CACHE_KV" id = "beb302875cfa41eb86fb24eeb3b9373a" +# preview: durable object bindings + +# fix for legacy issue on preview env deployments +[[env.preview.migrations]] +tag = "20250417" + +[[env.preview.migrations]] +tag = "20250102" +new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] + [[env.preview.durable_objects.bindings]] name = "HIRO_API_DO" class_name = "HiroApiDO" -[[env.preview.durable_objects.bindings]] -name = "STX_CITY_DO" -class_name = "StxCityDO" - [[env.preview.durable_objects.bindings]] name = "SUPABASE_DO" class_name = "SupabaseDO" +[[env.preview.durable_objects.bindings]] +name = "STX_CITY_DO" +class_name = "StxCityDO" + [[env.preview.durable_objects.bindings]] name = "BNS_API_DO" class_name = "BnsApiDO" +[[env.preview.migrations]] +tag = "20250323" +new_classes = ["ContractCallsDO"] + [[env.preview.durable_objects.bindings]] name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" +[[env.preview.migrations]] +tag = "20250530" +deleted_classes = ["ChainhooksDO"] + +[[env.preview.migrations]] +tag = "20250611" +new_classes = ["StacksAccountDO"] + +[[env.preview.durable_objects.bindings]] +name = "STACKS_ACCOUNT_DO" +class_name = "StacksAccountDO" [env.staging] +# staging: routes + routes = [{ pattern = "cache-staging.aibtc.dev", custom_domain = true }] +# staging: kv namespaces + [[env.staging.kv_namespaces]] binding = "AIBTCDEV_CACHE_KV" id = "beb302875cfa41eb86fb24eeb3b9373a" +# staging: durable object bindings + +# staging: durable object migrations + +[[env.staging.migrations]] +tag = "20250102" +new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] + [[env.staging.durable_objects.bindings]] name = "HIRO_API_DO" class_name = "HiroApiDO" -[[env.staging.durable_objects.bindings]] -name = "STX_CITY_DO" -class_name = "StxCityDO" - [[env.staging.durable_objects.bindings]] name = "SUPABASE_DO" class_name = "SupabaseDO" +[[env.staging.durable_objects.bindings]] +name = "STX_CITY_DO" +class_name = "StxCityDO" + [[env.staging.durable_objects.bindings]] name = "BNS_API_DO" class_name = "BnsApiDO" +[[env.staging.migrations]] +tag = "20250323" +new_classes = ["ContractCallsDO"] + [[env.staging.durable_objects.bindings]] name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" - [env.production] +# production: routes + routes = [{ pattern = "cache.aibtc.dev", custom_domain = true }] +# production: kv namespaces + [[env.production.kv_namespaces]] binding = "AIBTCDEV_CACHE_KV" id = "83cf92a7f68247c7bf1279f8a6163046" +# production: durable object bindings + [[env.production.durable_objects.bindings]] name = "HIRO_API_DO" class_name = "HiroApiDO" @@ -108,6 +143,13 @@ class_name = "SupabaseDO" name = "BNS_API_DO" class_name = "BnsApiDO" +# TODO: evaluate migrations for production +# if next production deployment fails + [[env.production.durable_objects.bindings]] name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" + +[[env.production.durable_objects.bindings]] +name = "STACKS_ACCOUNT_DO" +class_name = "StacksAccountDO"