Skip to content
81 changes: 81 additions & 0 deletions docs/feature-plans/stacks-account-do.md
Original file line number Diff line number Diff line change
@@ -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 `<WORKER_URL>` with the deployment URL and `<ADDRESS>` with a valid Stacks address.

1. **Get Nonce (Cache Miss/Hit):**
```bash
curl <WORKER_URL>/stacks-account/<ADDRESS>/nonce
```

2. **Force Refresh with `bustCache`:**
```bash
curl <WORKER_URL>/stacks-account/<ADDRESS>/nonce?bustCache=true
```

3. **Force Refresh with `sync`:**
```bash
curl -X POST <WORKER_URL>/stacks-account/<ADDRESS>/nonce/sync
```

4. **Manually Update Nonce:**
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"nonce": 999}' \
<WORKER_URL>/stacks-account/<ADDRESS>/nonce/update
```

5. **Test Invalid Address:**
```bash
curl <WORKER_URL>/stacks-account/INVALID-ADDRESS/nonce
```
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions src/durable-objects/stacks-account-do.ts
Original file line number Diff line number Diff line change
@@ -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<Env> {
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<Response> {
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<number>('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 };
}
}
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions src/services/stacks-account-data-service.ts
Original file line number Diff line number Diff line change
@@ -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<number>; // 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<number>(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<number> {
const network = getNetworkByPrincipal(address) as StacksNetworkName;

// Queue the request to respect rate limits
return this.requestQueue.enqueue(async () => {
return this.stacksApiService.getAccountNonce(address, network);
});
}
}
58 changes: 57 additions & 1 deletion src/services/stacks-api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<number> {
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,
});
}
}
}
1 change: 1 addition & 0 deletions worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface Env {
STX_CITY_DO: DurableObjectNamespace<import('./src/index').StxCityDO>;
SUPABASE_DO: DurableObjectNamespace<import('./src/index').SupabaseDO>;
CONTRACT_CALLS_DO: DurableObjectNamespace<import('./src/index').ContractCallsDO>;
STACKS_ACCOUNT_DO: DurableObjectNamespace<import('./src/index').StacksAccountDO>;
}
Loading