From ba6208f71fc7d7437bf6a1ae1d9bc213d1be4981 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Wed, 11 Jun 2025 12:51:45 -0700 Subject: [PATCH 1/9] feat: Implement StacksAccountDO for nonce management --- docs/feature-plans/stacks-account-do.md | 50 +++++++++ src/config.ts | 2 +- src/durable-objects/stacks-account-do.ts | 108 ++++++++++++++++++++ src/index.ts | 13 ++- src/services/stacks-account-data-service.ts | 44 ++++++++ src/services/stacks-api-service.ts | 58 +++++++++++ worker-configuration.d.ts | 1 + wrangler.toml | 16 +++ 8 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 docs/feature-plans/stacks-account-do.md create mode 100644 src/durable-objects/stacks-account-do.ts create mode 100644 src/services/stacks-account-data-service.ts diff --git a/docs/feature-plans/stacks-account-do.md b/docs/feature-plans/stacks-account-do.md new file mode 100644 index 0000000..0b1af2f --- /dev/null +++ b/docs/feature-plans/stacks-account-do.md @@ -0,0 +1,50 @@ +# 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 + +- [ ] **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. +- [ ] **Update `StacksApiService`**: Add a `getAccountNonce` method that uses `fetch` to call the Hiro `/extended/v1/addresses/{principal}/nonces` endpoint. +- [ ] **Create `StacksAccountDO`**: The main Durable Object class. + - [ ] Use the Stacks address from `ctx.id` as its identifier. + - [ ] Use `ctx.storage` for storing the nonce. + - [ ] Implement the `fetch` handler to route requests to the correct methods. + - [ ] Implement `getNonce`, `syncNonce`, and `updateNonce` logic. + - [ ] Use `StacksAccountDataService` for all external data fetching. + +### Phase 2: Configuration & Routing + +- [ ] **Update `wrangler.toml`**: + - [ ] Add a new migration for the `StacksAccountDO` class. + - [ ] Add the `STACKS_ACCOUNT_DO` binding to all environments (`preview`, `staging`, `production`). +- [ ] **Update `worker-configuration.d.ts`**: Add `STACKS_ACCOUNT_DO` to the `Env` interface. +- [ ] **Update `src/config.ts`**: Add `/stacks-account` to the `SUPPORTED_SERVICES` list. +- [ ] **Update `src/index.ts`**: + - [ ] Import and export the `StacksAccountDO` class. + - [ ] 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. 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..010b3ba --- /dev/null +++ b/src/durable-objects/stacks-account-do.ts @@ -0,0 +1,108 @@ +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; + private address: string; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.env = env; + this.address = ctx.id.toString(); + + 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); + // e.g., /stacks-account/{address}/nonce -> /nonce + const endpoint = url.pathname.replace(`/stacks-account/${this.address}`, '') || '/'; + const method = request.method; + + return handleRequest( + async () => { + if (!validateStacksAddress(this.address)) { + throw new ApiError(ErrorCode.INVALID_CONTRACT_ADDRESS, { address: this.address }); + } + + // Route to different functions based on the endpoint + if (endpoint.startsWith('/nonce')) { + return this.handleNonceRequest(request, endpoint); + } + + // Default response for the root of the DO + if (endpoint === '/') { + return { message: `StacksAccountDO for ${this.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): 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(bustCache); + } + + if (endpoint === '/nonce/sync' && method === 'POST') { + return this.syncNonce(); + } + + + + 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(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(); + } + + private async syncNonce(): Promise<{ nonce: number }> { + const nonce = await this.accountDataService.fetchNonce(this.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..35e1286 100644 --- a/src/services/stacks-api-service.ts +++ b/src/services/stacks-api-service.ts @@ -150,4 +150,62 @@ 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/addresses/${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..c2e0fb1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,6 +17,10 @@ new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] tag = "20250323" new_classes = ["ContractCallsDO"] +[[migrations]] +tag = "20250601" +new_classes = ["StacksAccountDO"] + [env.preview] @@ -54,6 +58,10 @@ class_name = "BnsApiDO" name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" +[[env.preview.durable_objects.bindings]] +name = "STACKS_ACCOUNT_DO" +class_name = "StacksAccountDO" + [env.staging] @@ -83,6 +91,10 @@ class_name = "BnsApiDO" name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" +[[env.staging.durable_objects.bindings]] +name = "STACKS_ACCOUNT_DO" +class_name = "StacksAccountDO" + [env.production] @@ -111,3 +123,7 @@ class_name = "BnsApiDO" [[env.production.durable_objects.bindings]] name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" + +[[env.production.durable_objects.bindings]] +name = "STACKS_ACCOUNT_DO" +class_name = "StacksAccountDO" From 3e57be6954f3b2ae865b240aa8a210982cdd4ab3 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Wed, 11 Jun 2025 12:52:58 -0700 Subject: [PATCH 2/9] chore: Mark StacksAccountDO implementation phases complete and format code --- docs/feature-plans/stacks-account-do.md | 32 ++++++++++++------------ src/durable-objects/stacks-account-do.ts | 2 -- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/feature-plans/stacks-account-do.md b/docs/feature-plans/stacks-account-do.md index 0b1af2f..d1c6e8a 100644 --- a/docs/feature-plans/stacks-account-do.md +++ b/docs/feature-plans/stacks-account-do.md @@ -19,25 +19,25 @@ Create a new Durable Object, `StacksAccountDO`, where each instance is uniquely ### Phase 1: Core Logic & Services -- [ ] **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. -- [ ] **Update `StacksApiService`**: Add a `getAccountNonce` method that uses `fetch` to call the Hiro `/extended/v1/addresses/{principal}/nonces` endpoint. -- [ ] **Create `StacksAccountDO`**: The main Durable Object class. - - [ ] Use the Stacks address from `ctx.id` as its identifier. - - [ ] Use `ctx.storage` for storing the nonce. - - [ ] Implement the `fetch` handler to route requests to the correct methods. - - [ ] Implement `getNonce`, `syncNonce`, and `updateNonce` logic. - - [ ] Use `StacksAccountDataService` for all external data fetching. +- [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 -- [ ] **Update `wrangler.toml`**: - - [ ] Add a new migration for the `StacksAccountDO` class. - - [ ] Add the `STACKS_ACCOUNT_DO` binding to all environments (`preview`, `staging`, `production`). -- [ ] **Update `worker-configuration.d.ts`**: Add `STACKS_ACCOUNT_DO` to the `Env` interface. -- [ ] **Update `src/config.ts`**: Add `/stacks-account` to the `SUPPORTED_SERVICES` list. -- [ ] **Update `src/index.ts`**: - - [ ] Import and export the `StacksAccountDO` class. - - [ ] Add routing logic to forward requests starting with `/stacks-account/{address}` to the correct DO instance. +- [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 diff --git a/src/durable-objects/stacks-account-do.ts b/src/durable-objects/stacks-account-do.ts index 010b3ba..34e286f 100644 --- a/src/durable-objects/stacks-account-do.ts +++ b/src/durable-objects/stacks-account-do.ts @@ -71,8 +71,6 @@ export class StacksAccountDO extends DurableObject { return this.syncNonce(); } - - if (endpoint === '/nonce/update' && method === 'POST') { const { nonce } = (await request.json()) as { nonce: number }; if (typeof nonce !== 'number') { From f11d89a3bf53606e7ce2d2967fb59625c8fcab1f Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Wed, 11 Jun 2025 12:58:29 -0700 Subject: [PATCH 3/9] docs: Add curl testing examples to Stacks Account DO feature plan --- docs/feature-plans/stacks-account-do.md | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/feature-plans/stacks-account-do.md b/docs/feature-plans/stacks-account-do.md index d1c6e8a..eb2aa21 100644 --- a/docs/feature-plans/stacks-account-do.md +++ b/docs/feature-plans/stacks-account-do.md @@ -48,3 +48,34 @@ Create a new Durable Object, `StacksAccountDO`, where each instance is uniquely - [ ] 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 + ``` From 4e5a38787cafbc004227e3bfeb09cdf30950f9fe Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 11 Jun 2025 14:01:24 -0700 Subject: [PATCH 4/9] fix: update structure for custom fetch function This enables the Hiro API key for our requests through the library. --- src/services/stacks-api-service.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/stacks-api-service.ts b/src/services/stacks-api-service.ts index 35e1286..a811553 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` @@ -171,11 +173,7 @@ export class StacksApiService { } try { - const response = await withTimeout( - fetch(url, { headers }), - this.timeoutMs, - `Nonce lookup for ${address} timed out` - ); + const response = await withTimeout(fetch(url, { headers }), this.timeoutMs, `Nonce lookup for ${address} timed out`); if (!response.ok) { const errorText = await response.text(); From b4818a8aac6c3cd912c1ce8df469eb95a78946f3 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 11 Jun 2025 14:05:07 -0700 Subject: [PATCH 5/9] fix: add migration tag for new DO --- wrangler.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wrangler.toml b/wrangler.toml index c2e0fb1..010f39e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -34,6 +34,10 @@ tag = "20250417" tag = "20250530" deleted_classes = ["ChainhooksDO"] +[[env.preview.migrations]] +tag = "20250611" +new_classes = ["StacksAccountDO"] + [[env.preview.kv_namespaces]] binding = "AIBTCDEV_CACHE_KV" id = "beb302875cfa41eb86fb24eeb3b9373a" From 81743e1d9895b17699f930bccfe7cc07f54cdb7f Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 11 Jun 2025 14:09:08 -0700 Subject: [PATCH 6/9] fix: reorder migrations per deployment --- wrangler.toml | 64 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index 010f39e..81ec1d6 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,28 +8,28 @@ account_id = "96280594e2b905d4dc40b3c744149710" [observability] enabled = true -# durable object migrations -[[migrations]] -tag = "20250102" -new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] - -[[migrations]] -tag = "20250323" -new_classes = ["ContractCallsDO"] - -[[migrations]] -tag = "20250601" -new_classes = ["StacksAccountDO"] - +## NOTE: everything organized by environment [env.preview] +# preview: routes + +# auto-generated by Wrangler routes = [] -# fixing a deploy error from old code +# preview: durable object migrations + [[env.preview.migrations]] tag = "20250417" +[[env.preview.migrations]] +tag = "20250102" +new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] + +[[env.preview.migrations]] +tag = "20250323" +new_classes = ["ContractCallsDO"] + [[env.preview.migrations]] tag = "20250530" deleted_classes = ["ChainhooksDO"] @@ -38,10 +38,14 @@ deleted_classes = ["ChainhooksDO"] tag = "20250611" new_classes = ["StacksAccountDO"] +# preview: kv namespaces + [[env.preview.kv_namespaces]] binding = "AIBTCDEV_CACHE_KV" id = "beb302875cfa41eb86fb24eeb3b9373a" +# preview: durable object bindings + [[env.preview.durable_objects.bindings]] name = "HIRO_API_DO" class_name = "HiroApiDO" @@ -66,15 +70,30 @@ class_name = "ContractCallsDO" name = "STACKS_ACCOUNT_DO" class_name = "StacksAccountDO" - [env.staging] +# staging: routes + routes = [{ pattern = "cache-staging.aibtc.dev", custom_domain = true }] +# staging: durable object migrations + +[[env.staging.migrations]] +tag = "20250102" +new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] + +[[env.staging.migrations]] +tag = "20250323" +new_classes = ["ContractCallsDO"] + +# staging: kv namespaces + [[env.staging.kv_namespaces]] binding = "AIBTCDEV_CACHE_KV" id = "beb302875cfa41eb86fb24eeb3b9373a" +# staging: durable object bindings + [[env.staging.durable_objects.bindings]] name = "HIRO_API_DO" class_name = "HiroApiDO" @@ -95,19 +114,24 @@ class_name = "BnsApiDO" name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" -[[env.staging.durable_objects.bindings]] -name = "STACKS_ACCOUNT_DO" -class_name = "StacksAccountDO" - - [env.production] +# production: routes + routes = [{ pattern = "cache.aibtc.dev", custom_domain = true }] +# production: durable object migrations + +# (none yet) + +# 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" From c541bd9b2e8a5e746e4c7787481b06789dd60b85 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 11 Jun 2025 14:12:46 -0700 Subject: [PATCH 7/9] fix: merge durable objects defs and migrations into one category Makes it easier to understand the flow of each env since any changes to DOs require a migration that outlines the change for new or deleting. --- wrangler.toml | 84 +++++++++++++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index 81ec1d6..1cacd63 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,27 +17,6 @@ enabled = true # auto-generated by Wrangler routes = [] -# preview: durable object migrations - -[[env.preview.migrations]] -tag = "20250417" - -[[env.preview.migrations]] -tag = "20250102" -new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] - -[[env.preview.migrations]] -tag = "20250323" -new_classes = ["ContractCallsDO"] - -[[env.preview.migrations]] -tag = "20250530" -deleted_classes = ["ChainhooksDO"] - -[[env.preview.migrations]] -tag = "20250611" -new_classes = ["StacksAccountDO"] - # preview: kv namespaces [[env.preview.kv_namespaces]] @@ -46,26 +25,46 @@ 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" @@ -76,16 +75,6 @@ class_name = "StacksAccountDO" routes = [{ pattern = "cache-staging.aibtc.dev", custom_domain = true }] -# staging: durable object migrations - -[[env.staging.migrations]] -tag = "20250102" -new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] - -[[env.staging.migrations]] -tag = "20250323" -new_classes = ["ContractCallsDO"] - # staging: kv namespaces [[env.staging.kv_namespaces]] @@ -94,22 +83,32 @@ 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" @@ -120,10 +119,6 @@ class_name = "ContractCallsDO" routes = [{ pattern = "cache.aibtc.dev", custom_domain = true }] -# production: durable object migrations - -# (none yet) - # production: kv namespaces [[env.production.kv_namespaces]] @@ -148,6 +143,9 @@ 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" From ffd3d2a42f0663ad3a42b2c76201f77e6defcced Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 11 Jun 2025 14:45:52 -0700 Subject: [PATCH 8/9] refactor: Get address from path in StacksAccountDO --- src/durable-objects/stacks-account-do.ts | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/durable-objects/stacks-account-do.ts b/src/durable-objects/stacks-account-do.ts index 34e286f..8564592 100644 --- a/src/durable-objects/stacks-account-do.ts +++ b/src/durable-objects/stacks-account-do.ts @@ -9,12 +9,10 @@ import { validateStacksAddress } from '@stacks/transactions'; export class StacksAccountDO extends DurableObject { private accountDataService: StacksAccountDataService; - private address: string; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.env = env; - this.address = ctx.id.toString(); const config = AppConfig.getInstance(env).getConfig(); const hiroConfig = config.HIRO_API_RATE_LIMIT; @@ -31,24 +29,26 @@ export class StacksAccountDO extends DurableObject { 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/${this.address}`, '') || '/'; + const endpoint = url.pathname.replace(`/stacks-account/${address}`, '') || '/'; const method = request.method; return handleRequest( async () => { - if (!validateStacksAddress(this.address)) { - throw new ApiError(ErrorCode.INVALID_CONTRACT_ADDRESS, { address: this.address }); + 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); + return this.handleNonceRequest(request, endpoint, address); } // Default response for the root of the DO if (endpoint === '/') { - return { message: `StacksAccountDO for ${this.address}. Supported endpoints: /nonce` }; + return { message: `StacksAccountDO for ${address}. Supported endpoints: /nonce` }; } throw new ApiError(ErrorCode.NOT_FOUND, { resource: endpoint }); @@ -58,17 +58,17 @@ export class StacksAccountDO extends DurableObject { ); } - private async handleNonceRequest(request: Request, endpoint: string): Promise<{ nonce: number }> { + 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(bustCache); + return this.getNonce(address, bustCache); } if (endpoint === '/nonce/sync' && method === 'POST') { - return this.syncNonce(); + return this.syncNonce(address); } if (endpoint === '/nonce/update' && method === 'POST') { @@ -82,7 +82,7 @@ export class StacksAccountDO extends DurableObject { throw new ApiError(ErrorCode.INVALID_REQUEST, { reason: `Method ${method} not supported for ${endpoint}` }); } - private async getNonce(bustCache: boolean): Promise<{ nonce: number }> { + private async getNonce(address: string, bustCache: boolean): Promise<{ nonce: number }> { if (!bustCache) { const storedNonce = await this.ctx.storage.get('nonce'); if (storedNonce !== undefined) { @@ -90,11 +90,11 @@ export class StacksAccountDO extends DurableObject { } } // If cache is busted or nonce is not in storage, sync it - return this.syncNonce(); + return this.syncNonce(address); } - private async syncNonce(): Promise<{ nonce: number }> { - const nonce = await this.accountDataService.fetchNonce(this.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 }; } From 88b234b846a4d3763faa718c4d5b78b5bda01aac Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 11 Jun 2025 15:43:38 -0700 Subject: [PATCH 9/9] fix: typo in hiro api url --- src/services/stacks-api-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/stacks-api-service.ts b/src/services/stacks-api-service.ts index a811553..cac6b28 100644 --- a/src/services/stacks-api-service.ts +++ b/src/services/stacks-api-service.ts @@ -165,7 +165,7 @@ export class StacksApiService { const startTime = Date.now(); const requestId = logger.info(`Fetching nonce for address: ${address} on ${network}`); - const url = `https://api.${network}.hiro.so/extended/v1/addresses/${address}/nonces`; + const url = `https://api.${network}.hiro.so/extended/v1/address/${address}/nonces`; const headers: HeadersInit = {}; if (this.env?.HIRO_API_KEY) {