diff --git a/.github/workflows/ci-main.yaml b/.github/workflows/ci-main.yaml new file mode 100644 index 0000000..ff11e8d --- /dev/null +++ b/.github/workflows/ci-main.yaml @@ -0,0 +1,30 @@ +name: CI Main + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Run CI + run: bun run ci + + - name: Publish to NPM package registry + run: npm publish --access=public --tag=latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }} diff --git a/.github/workflows/ci-pr.yaml b/.github/workflows/ci-pr.yaml new file mode 100644 index 0000000..7572085 --- /dev/null +++ b/.github/workflows/ci-pr.yaml @@ -0,0 +1,46 @@ +name: CI PR + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Run CI + run: bun run ci + + - id: current-version + name: Get current version + run: echo "CURRENT_VERSION=$(npm pkg get version | tr -d '"')" >> $GITHUB_OUTPUT + + - id: sha + name: Get commit sha + run: echo "SHA=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set publish version + # https://github.com/oven-sh/bun/issues/1976 + run: bunx npm@latest version --no-git-tag-version $CURRENT_VERSION-$SHA + env: + SHA: ${{ steps.sha.outputs.SHA }} + CURRENT_VERSION: ${{ steps.current-version.outputs.CURRENT_VERSION }} + + - name: Publish to NPM package registry + # https://github.com/oven-sh/bun/issues/1976 + run: bunx npm@latest publish --access=public --tag pr-$PR_NUMBER + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }} + PR_NUMBER: ${{ github.event.number }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c211a3 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# pool-tools + +A collection of methods to help interact with the Stacks API and manage pools. + +Supports ESM imports only. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..bb60c35 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..29e7b00 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "stacks-tools", + "version": "0.1.0", + "type": "module", + "files": [ + "dist" + ], + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "check-format": "prettier --check .", + "format": "prettier --write .", + "check-types": "tsc --noEmit", + "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm", + "ci": "bun run check-format && bun run check-exports && bun run build" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.15.4", + "@types/bun": "latest", + "prettier": "^3.3.3" + }, + "peerDependencies": { + "typescript": "^5.0.0", + "valibot": "^0.41.0" + }, + "dependencies": { + "exponential-backoff": "3.1.1" + }, + "license": "MIT" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..80aa217 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export { stacksApi } from "./stacks-api/index.js"; +export { queries } from "./queries/index.js"; diff --git a/src/pox4-api/README.md b/src/pox4-api/README.md new file mode 100644 index 0000000..d4e4946 --- /dev/null +++ b/src/pox4-api/README.md @@ -0,0 +1,29 @@ +# Pool API + +## Public + +- delegate-stx +- delegate-stack-stx +- delegate-stack-stx-simple +- set-metadata +- set-metadata-many +- allow-contract-caller +- disallow-contract-caller + +## Read only + +- burn-height-to-reward-cycle +- reward-cycle-to-burn-height +- current-pox-reward-cycle +- get-status +- get-status-lists-last-index +- get-status-list +- get-delegated-amount +- get-user-data +- get-stx-account +- get-total +- not-locked-for-cycle +- get-metadata +- get-metadata-many +- check-caller-allowed +- get-allowance-contract-callers diff --git a/src/pox4-api/burn-height-to-reward-cycle.ts b/src/pox4-api/burn-height-to-reward-cycle.ts new file mode 100644 index 0000000..242fefc --- /dev/null +++ b/src/pox4-api/burn-height-to-reward-cycle.ts @@ -0,0 +1,13 @@ +import type { ApiRequestOptions } from "../stacks-api/types.js"; +import { success, type Result } from "../utils/safe.js"; + +type Args = { + burnHeight: number; +} & ApiRequestOptions; + +export async function burnHeightToRewardCycle( + _args: Args, +): Promise> { + // TODO + return success(0); +} diff --git a/src/pox4-api/current-pox-reward-cycle.ts b/src/pox4-api/current-pox-reward-cycle.ts new file mode 100644 index 0000000..70b786d --- /dev/null +++ b/src/pox4-api/current-pox-reward-cycle.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/pox4-api/get-stacker-info.ts b/src/pox4-api/get-stacker-info.ts new file mode 100644 index 0000000..70b786d --- /dev/null +++ b/src/pox4-api/get-stacker-info.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/pox4-api/reward-cycle-to-burn-height.ts b/src/pox4-api/reward-cycle-to-burn-height.ts new file mode 100644 index 0000000..70b786d --- /dev/null +++ b/src/pox4-api/reward-cycle-to-burn-height.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/queries/get-signer-total-locked.ts b/src/queries/get-signer-total-locked.ts new file mode 100644 index 0000000..147700b --- /dev/null +++ b/src/queries/get-signer-total-locked.ts @@ -0,0 +1,71 @@ +import { signersInCycle } from "../stacks-api/proof-of-transfer/signers-in-cycle.js"; +import type { ApiRequestOptions } from "../stacks-api/types.js"; +import { safeCallRateLimitedApi } from "../utils/call-rate-limited-api.js"; +import { + error as safeError, + success, + type Result, + type SafeError, +} from "../utils/safe.js"; + +export type Args = { + cycleNumber: number; + signerAddress: string; +} & ApiRequestOptions; + +/** + * Return the total locked amount for a signer in a PoX cycle. + */ +export async function getSignerTotalLocked( + args: Args, +): Promise>> { + let totalLocked = 0n; + + const { signerAddress, ...rest } = args; + + let hasMore = true; + let offset = 0; + let found = false; + const limit = 200; + while (hasMore && !found) { + const [error, data] = await safeCallRateLimitedApi(() => + signersInCycle({ + ...rest, + limit, + }), + ); + + if (error) { + return safeError({ + name: "GetSignerTotalLockedError", + message: "Failed to get signer total locked.", + data: { + error, + }, + }); + } + + for (const signer of data.results) { + if (signer.signer_address === signerAddress) { + totalLocked = BigInt(signer.stacked_amount); + found = true; + break; + } + } + + offset += limit + data.results.length; + hasMore = offset < data.total; + } + + if (!found) { + return safeError({ + name: "SignerNotFound", + message: "Signer not found.", + data: { + signerAddress, + }, + }); + } + + return success(totalLocked); +} diff --git a/src/queries/index.ts b/src/queries/index.ts new file mode 100644 index 0000000..7c2f4e5 --- /dev/null +++ b/src/queries/index.ts @@ -0,0 +1,5 @@ +import { getSignerTotalLocked } from "./get-signer-total-locked.js"; + +export const queries = { + getSignerTotalLocked, +}; diff --git a/src/stacks-api/README.md b/src/stacks-api/README.md new file mode 100644 index 0000000..6e9ad5d --- /dev/null +++ b/src/stacks-api/README.md @@ -0,0 +1,20 @@ +# Hiro API + +Helper methods to call Hiro's Stacks API. The helpers aim to be more convenient than raw `fetch` calls by + +- typing all arguments, regardless of whether they end up as URL parameters or in the request body +- typing responses +- wrapping errors in a safe `Result` rather than throwing on error +- runtime type-checking response data to easily identify API schema changes + +Currently not all endpoints have a helper method. This is a work in progress. + +Each endpoint is in its own file, with the name of the path following that used by the Hiro docs. For example, a helper for an endpoint documented at `https://docs.hiro.so/stacks/api/{category}/{endpoint-name}` would be available as `endpointName()` from `./category/endpoint-name.ts`. + +## Doesn't Hiro already have an API client? + +The API client provided by Hiro is `class` based and keeps internal state. The helpers provided here are pure functions. + +Hiro's client is not the most straight forward to use, and [requires users to remember the HTTP verb and path of the endpoint](https://github.com/hirosystems/stacks-blockchain-api/blob/develop/client/MIGRATION.md#performing-requests). The methods included here use more memorable names and take care of using the correct verb for each endpoint. + +Finally, Hiro's API client requires request parameters to be configured in several places as the API implementaiton bleeds into the client API. The methods here conveniently use a single config object. diff --git a/src/stacks-api/accounts/balances.ts b/src/stacks-api/accounts/balances.ts new file mode 100644 index 0000000..5c9506e --- /dev/null +++ b/src/stacks-api/accounts/balances.ts @@ -0,0 +1,102 @@ +import { + error, + safePromise, + success, + type Result, + type SafeError, +} from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +export type Args = { + principal: string; + unanchored?: boolean; + untilBlock?: number; +} & ApiRequestOptions; + +export const responseSchema = v.object({ + stx: v.object({ + balance: v.string(), + total_sent: v.string(), + total_received: v.string(), + total_fees_sent: v.string(), + total_miner_rewards_received: v.string(), + lock_tx_id: v.string(), + locked: v.string(), + lock_height: v.number(), + burnchain_lock_height: v.number(), + burnchain_unlock_height: v.number(), + }), + fungible_tokens: v.record( + v.string(), + v.object({ + balance: v.string(), + total_sent: v.string(), + total_received: v.string(), + }), + ), + non_fungible_tokens: v.record( + v.string(), + v.object({ + count: v.string(), + total_sent: v.string(), + total_received: v.string(), + }), + ), +}); +export type Response = v.InferOutput; + +export async function balances( + opts: Args, +): Promise< + Result< + Response, + SafeError<"FetchBalancesError" | "ParseBodyError" | "ValidateDataError"> + > +> { + const search = new URLSearchParams(); + if (opts.unanchored) search.append("unanchored", "true"); + if (opts.untilBlock) search.append("until_block", opts.untilBlock.toString()); + + const init: RequestInit = {}; + if (opts.apiKeyConfig) { + init.headers = { + [opts.apiKeyConfig.header]: opts.apiKeyConfig.key, + }; + } + + const endpoint = `${opts.baseUrl}/extended/v1/address/${opts.principal}/balances?${search}`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchBalancesError", + message: "Failed to fetch balances.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonError, + }); + } + + const validationResult = v.safeParse(responseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/accounts/index.ts b/src/stacks-api/accounts/index.ts new file mode 100644 index 0000000..e524197 --- /dev/null +++ b/src/stacks-api/accounts/index.ts @@ -0,0 +1,5 @@ +import { balances } from "./balances.js"; + +export const accounts = { + balances, +}; diff --git a/src/stacks-api/blocks/get-block.ts b/src/stacks-api/blocks/get-block.ts new file mode 100644 index 0000000..05fe089 --- /dev/null +++ b/src/stacks-api/blocks/get-block.ts @@ -0,0 +1,89 @@ +import { + error, + safePromise, + success, + type Result, + type SafeError, +} from "../../utils/safe.js"; +import { type ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +export type Args = { + heightOrHash: string | number; +} & ApiRequestOptions; + +export const responseSchema = v.object({ + canonical: v.boolean(), + height: v.number(), + hash: v.string(), + block_time: v.number(), + block_time_iso: v.string(), + index_block_hash: v.string(), + parent_block_hash: v.string(), + parent_index_block_hash: v.string(), + burn_block_time: v.number(), + burn_block_time_iso: v.string(), + burn_block_hash: v.string(), + burn_block_height: v.number(), + miner_txid: v.string(), + tx_count: v.number(), + execution_cost_read_count: v.number(), + execution_cost_read_length: v.number(), + execution_cost_runtime: v.number(), + execution_cost_write_count: v.number(), + execution_cost_write_length: v.number(), +}); +export type Response = v.InferOutput; + +export async function getBlock( + opts: Args, +): Promise< + Result< + Response, + SafeError<"FetchBlockError" | "ParseBodyError" | "ValidateDataError"> + > +> { + const init: RequestInit = {}; + if (opts.apiKeyConfig) { + init.headers = { + [opts.apiKeyConfig.header]: opts.apiKeyConfig.key, + }; + } + + const res = await fetch( + `${opts.baseUrl}/extended/v2/blocks/${opts.heightOrHash}`, + init, + ); + + if (!res.ok) { + return error({ + name: "FetchBlockError", + message: "Failed to fetch block.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse body.", + data: jsonError, + }); + } + + const validationResult = v.safeParse(responseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/blocks/index.ts b/src/stacks-api/blocks/index.ts new file mode 100644 index 0000000..ff3341a --- /dev/null +++ b/src/stacks-api/blocks/index.ts @@ -0,0 +1,5 @@ +import { getBlock } from "./get-block.js"; + +export const blocks = { + getBlock, +}; diff --git a/src/stacks-api/index.ts b/src/stacks-api/index.ts new file mode 100644 index 0000000..f78a702 --- /dev/null +++ b/src/stacks-api/index.ts @@ -0,0 +1,17 @@ +import { accounts } from "./accounts/index.js"; +import { blocks } from "./blocks/index.js"; +import { info } from "./info/index.js"; +import { proofOfTransfer } from "./proof-of-transfer/index.js"; +import { smartContracts } from "./smart-contracts/index.js"; +import { stackingPool } from "./stacking-pool/index.js"; +import { transactions } from "./transactions/index.js"; + +export const stacksApi = { + accounts, + blocks, + info, + proofOfTransfer, + smartContracts, + stackingPool, + transactions, +}; diff --git a/src/stacks-api/info/core-api.ts b/src/stacks-api/info/core-api.ts new file mode 100644 index 0000000..52f0396 --- /dev/null +++ b/src/stacks-api/info/core-api.ts @@ -0,0 +1,64 @@ +import { error, safePromise, success, type Result } from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +const CoreApiResponseSchema = v.object({ + peer_version: v.number(), + pox_consensus: v.string(), + burn_block_height: v.number(), + stable_pox_consensus: v.string(), + stable_burn_block_height: v.number(), + server_version: v.string(), + network_id: v.number(), + parent_network_id: v.number(), + stacks_tip_height: v.number(), + stacks_tip: v.string(), + stacks_tip_consensus_hash: v.string(), + unanchored_tip: v.string(), + exit_at_block_height: v.number(), +}); +export type CoreApiResponse = v.InferOutput; + +export async function coreApi( + apiOpts: ApiRequestOptions, +): Promise> { + const init: RequestInit = {}; + if (apiOpts.apiKeyConfig) { + init.headers = { + [apiOpts.apiKeyConfig.header]: apiOpts.apiKeyConfig.key, + }; + } + const res = await fetch(`${apiOpts.baseUrl}/v2/info`, init); + + if (!res.ok) { + return error({ + name: "FetchCoreApiError", + message: "Failed to fetch.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [parseBodyError, data] = await safePromise(res.json()); + if (parseBodyError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: parseBodyError, + }); + } + + const validationResult = v.safeParse(CoreApiResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/info/index.ts b/src/stacks-api/info/index.ts new file mode 100644 index 0000000..2334266 --- /dev/null +++ b/src/stacks-api/info/index.ts @@ -0,0 +1,7 @@ +import { coreApi } from "./core-api.js"; +import { poxDetails } from "./pox-details.js"; + +export const info = { + coreApi, + poxDetails, +}; diff --git a/src/stacks-api/info/pox-details.ts b/src/stacks-api/info/pox-details.ts new file mode 100644 index 0000000..16e6ea0 --- /dev/null +++ b/src/stacks-api/info/pox-details.ts @@ -0,0 +1,108 @@ +import { error, safePromise, success, type Result } from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +type Args = ApiRequestOptions; + +const poxDetailsResponseSchema = v.object({ + contract_id: v.string(), + pox_activation_threshold_ustx: v.number(), + first_burnchain_block_height: v.number(), + current_burnchain_block_height: v.number(), + prepare_phase_block_length: v.number(), + reward_phase_block_length: v.number(), + reward_slots: v.number(), + rejection_fraction: v.null(), + total_liquid_supply_ustx: v.number(), + current_cycle: v.object({ + id: v.number(), + min_threshold_ustx: v.number(), + stacked_ustx: v.number(), + is_pox_active: v.boolean(), + }), + next_cycle: v.object({ + id: v.number(), + min_threshold_ustx: v.number(), + min_increment_ustx: v.number(), + stacked_ustx: v.number(), + prepare_phase_start_block_height: v.number(), + blocks_until_prepare_phase: v.number(), + reward_phase_start_block_height: v.number(), + blocks_until_reward_phase: v.number(), + ustx_until_pox_rejection: v.null(), + }), + epochs: v.array( + v.object({ + epoch_id: v.string(), + start_height: v.number(), + end_height: v.number(), + block_limit: v.object({ + write_length: v.number(), + write_count: v.number(), + read_length: v.number(), + read_count: v.number(), + runtime: v.number(), + }), + network_epoch: v.number(), + }), + ), + min_amount_ustx: v.number(), + prepare_cycle_length: v.number(), + reward_cycle_id: v.number(), + reward_cycle_length: v.number(), + rejection_votes_left_required: v.null(), + next_reward_cycle_in: v.number(), + contract_versions: v.array( + v.object({ + contract_id: v.string(), + activation_burnchain_block_height: v.number(), + first_reward_cycle_id: v.number(), + }), + ), +}); +type PoxDetailsResponse = v.InferOutput; + +export async function poxDetails( + args: Args, +): Promise> { + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + + const res = await fetch(`${args.baseUrl}/v2/pox`, init); + + if (!res.ok) { + return error({ + name: "FetchPoxDetailsError", + message: "Failed to fetch pox details.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonParseError, data] = await safePromise(res.json()); + if (jsonParseError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse pox details response.", + data: jsonParseError, + }); + } + + const validationResult = v.safeParse(poxDetailsResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to parse pox details response.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/proof-of-transfer/cycle.ts b/src/stacks-api/proof-of-transfer/cycle.ts new file mode 100644 index 0000000..798ff36 --- /dev/null +++ b/src/stacks-api/proof-of-transfer/cycle.ts @@ -0,0 +1,75 @@ +import { + error, + safePromise, + success, + type Result, + type SafeError, +} from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +export type Args = { + cycleNumber: number; +} & ApiRequestOptions; + +export const responseSchema = v.object({ + block_height: v.number(), + index_block_hash: v.string(), + cycle_number: v.number(), + total_weight: v.number(), + total_stacked_amount: v.string(), + total_signers: v.number(), +}); +export type Response = v.InferOutput; + +export async function cycle( + opts: Args, +): Promise< + Result< + Response, + SafeError<"FetchCycleError" | "ParseBodyError" | "ValidateDataError"> + > +> { + const init: RequestInit = {}; + if (opts.apiKeyConfig) { + init.headers = { + [opts.apiKeyConfig.header]: opts.apiKeyConfig.key, + }; + } + + const endpoint = `${opts.baseUrl}/extended/v2/pox/cycles/${opts.cycleNumber}`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchCycleError", + message: "Failed to fetch cycle.", + data: { + endpoint, + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonError, + }); + } + + const validationResult = v.safeParse(responseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/proof-of-transfer/cycles.ts b/src/stacks-api/proof-of-transfer/cycles.ts new file mode 100644 index 0000000..86b20b8 --- /dev/null +++ b/src/stacks-api/proof-of-transfer/cycles.ts @@ -0,0 +1,76 @@ +import { error, safePromise, success, type Result } from "../../utils/safe.js"; +import { + baseListResponseSchema, + type ApiPaginationOptions, + type ApiRequestOptions, +} from "../types.js"; +import * as v from "valibot"; + +export type Args = ApiRequestOptions & ApiPaginationOptions; + +export const cycleInfoSchema = v.object({ + block_height: v.number(), + index_block_hash: v.string(), + cycle_number: v.number(), + total_weight: v.number(), + total_stacked_amount: v.string(), + total_signers: v.number(), +}); +export type CycleInfo = v.InferOutput; + +export const resultsSchema = v.array(cycleInfoSchema); +export type Results = v.InferOutput; + +export const cyclesResponseSchema = v.object({ + ...baseListResponseSchema.entries, + results: resultsSchema, +}); +export type CyclesResponse = v.InferOutput; + +export async function cycles(args: Args): Promise> { + const search = new URLSearchParams(); + if (args.limit) search.append("limit", args.limit.toString()); + if (args.offset) search.append("offset", args.offset.toString()); + + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + const endpoint = `${args.baseUrl}/extended/v2/pox/cycles`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchCyclesError", + message: "Failed to fetch cycles.", + data: { + endpoint, + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonError, + }); + } + + const validationResult = v.safeParse(cyclesResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate response data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/proof-of-transfer/index.ts b/src/stacks-api/proof-of-transfer/index.ts new file mode 100644 index 0000000..3eac12f --- /dev/null +++ b/src/stacks-api/proof-of-transfer/index.ts @@ -0,0 +1,11 @@ +import { cycle } from "./cycle.js"; +import { cycles } from "./cycles.js"; +import { signersInCycle } from "./signers-in-cycle.js"; +import { stackersForSignerInCycle } from "./stackers-for-signer-in-cycle.js"; + +export const proofOfTransfer = { + cycle, + cycles, + signersInCycle, + stackersForSignerInCycle, +}; diff --git a/src/stacks-api/proof-of-transfer/signers-in-cycle.ts b/src/stacks-api/proof-of-transfer/signers-in-cycle.ts new file mode 100644 index 0000000..2d25461 --- /dev/null +++ b/src/stacks-api/proof-of-transfer/signers-in-cycle.ts @@ -0,0 +1,97 @@ +import { + error, + safePromise, + success, + type Result, + type SafeError, +} from "../../utils/safe.js"; +import { + baseListResponseSchema, + type ApiPaginationOptions, + type ApiRequestOptions, +} from "../types.js"; +import * as v from "valibot"; + +export type Args = { + cycleNumber: number; +} & ApiRequestOptions & + ApiPaginationOptions; + +export const signerSchema = v.object({ + signing_key: v.string(), + signer_address: v.string(), + weight: v.number(), + stacked_amount: v.string(), + weight_percent: v.number(), + stacked_amount_percent: v.number(), + pooled_stacker_count: v.number(), + solo_stacker_count: v.number(), +}); +export type Signer = v.InferOutput; + +export const resultsSchema = v.array(signerSchema); +export type Results = v.InferOutput; + +export const signersResponseSchema = v.object({ + ...baseListResponseSchema.entries, + results: resultsSchema, +}); +export type SignersResponse = v.InferOutput; + +export async function signersInCycle( + args: Args, +): Promise< + Result< + SignersResponse, + SafeError<"FetchSignersError" | "ParseBodyError" | "ValidateDataError"> + > +> { + const search = new URLSearchParams(); + if (args.limit) search.append("limit", args.limit.toString()); + if (args.offset) search.append("offset", args.offset.toString()); + + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + const endpoint = `${args.baseUrl}/extended/v2/pox/cycles/${args.cycleNumber}/signers`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchSignersError", + message: "Failed to fetch signers.", + data: { + endpoint, + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: { + endpoint, + bodyParseResult: data, + }, + }); + } + + const validationResult = v.safeParse(signersResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate response data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/proof-of-transfer/stackers-for-signer-in-cycle.ts b/src/stacks-api/proof-of-transfer/stackers-for-signer-in-cycle.ts new file mode 100644 index 0000000..db819e3 --- /dev/null +++ b/src/stacks-api/proof-of-transfer/stackers-for-signer-in-cycle.ts @@ -0,0 +1,101 @@ +import { + error, + safePromise, + success, + type Result, + type SafeError, +} from "../../utils/safe.js"; +import { + baseListResponseSchema, + type ApiPaginationOptions, + type ApiRequestOptions, +} from "../types.js"; +import * as v from "valibot"; + +export type Args = { + cycleNumber: number; + signerPublicKey: string; +} & ApiRequestOptions & + ApiPaginationOptions; + +export const stackerInfoSchema = v.object({ + stacker_address: v.string(), + stacked_amount: v.string(), + pox_address: v.string(), + stacker_type: v.union([v.literal("pooled"), v.literal("solo")]), +}); +export type StackerInfo = v.InferOutput; + +export const resultsSchema = v.array(stackerInfoSchema); +export type Results = v.InferOutput; + +export const stackersForSignerInCycleResponseSchema = v.object({ + ...baseListResponseSchema.entries, + results: resultsSchema, +}); +export type StackersForSignerInCycleResponse = v.InferOutput< + typeof stackersForSignerInCycleResponseSchema +>; + +export async function stackersForSignerInCycle( + opts: Args, +): Promise< + Result< + StackersForSignerInCycleResponse, + SafeError< + | "FetchStackersForSignerInCycleError" + | "ParseBodyError" + | "ValidateDataError" + > + > +> { + const search = new URLSearchParams(); + if (opts.limit) search.append("limit", opts.limit.toString()); + if (opts.offset) search.append("offset", opts.offset.toString()); + + const init: RequestInit = {}; + if (opts.apiKeyConfig) { + init.headers = { + [opts.apiKeyConfig.header]: opts.apiKeyConfig.key, + }; + } + + const endpoint = `${opts.baseUrl}/extended/v2/pox/cycles/${opts.cycleNumber}/signers/${opts.signerPublicKey}/stackers?${search}`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchStackersForSignerInCycleError", + message: "Failed to fetch stackers for signer in cycle.", + data: { + endpoint, + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonError, + }); + } + + const validationResult = v.safeParse( + stackersForSignerInCycleResponseSchema, + data, + ); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate response data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/smart-contracts/index.ts b/src/stacks-api/smart-contracts/index.ts new file mode 100644 index 0000000..0412e1f --- /dev/null +++ b/src/stacks-api/smart-contracts/index.ts @@ -0,0 +1,5 @@ +import { readOnly } from "./read-only.js"; + +export const smartContracts = { + readOnly, +}; diff --git a/src/stacks-api/smart-contracts/read-only.ts b/src/stacks-api/smart-contracts/read-only.ts new file mode 100644 index 0000000..ac3dfb3 --- /dev/null +++ b/src/stacks-api/smart-contracts/read-only.ts @@ -0,0 +1,72 @@ +import { error, safePromise, success, type Result } from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +export type Options = { + sender: string; + arguments: string[]; + contractAddress: string; + contractName: string; + functionName: string; +}; + +export const readOnlyResponseSchema = v.variant("okay", [ + v.object({ + okay: v.literal(true), + result: v.string(), + }), + v.object({ + okay: v.literal(false), + cause: v.unknown(), + }), +]); +export type ReadOnlyResponse = v.InferOutput; + +export async function readOnly( + opts: Options, + apiOpts: ApiRequestOptions, +): Promise> { + const init: RequestInit = {}; + if (apiOpts.apiKeyConfig) { + init.headers = { + [apiOpts.apiKeyConfig.header]: apiOpts.apiKeyConfig.key, + }; + } + + const res = await fetch( + `${apiOpts.baseUrl}/v2/contracts/call-read/${opts.contractAddress}/${opts.contractName}/${opts.functionName}`, + init, + ); + + if (!res.ok) { + return error({ + name: "FetchReadOnlyError", + message: "Failed to fetch.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: error, + }); + } + + const validationResult = v.safeParse(readOnlyResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/stacking-pool/index.ts b/src/stacks-api/stacking-pool/index.ts new file mode 100644 index 0000000..e4a19de --- /dev/null +++ b/src/stacks-api/stacking-pool/index.ts @@ -0,0 +1,5 @@ +import { members } from "./members.js"; + +export const stackingPool = { + members, +}; diff --git a/src/stacks-api/stacking-pool/members.ts b/src/stacks-api/stacking-pool/members.ts new file mode 100644 index 0000000..c571bce --- /dev/null +++ b/src/stacks-api/stacking-pool/members.ts @@ -0,0 +1,84 @@ +import { error, safePromise, success, type Result } from "../../utils/safe.js"; +import { type ApiRequestOptions } from "../types.js"; +import * as v from "valibot"; + +export type Options = { + poolPrincipal: string; + afterBlock?: number; + unanchored?: boolean; + limit?: number; + offset?: number; +}; + +export const memberSchema = v.object({ + stacker: v.string(), + pox_addr: v.optional(v.string()), + amount_ustx: v.string(), + burn_block_unlock_height: v.optional(v.number()), + block_height: v.number(), + tx_id: v.string(), +}); +export type Member = v.InferOutput; + +export const membersResponseSchema = v.object({ + limit: v.number(), + offset: v.number(), + total: v.number(), + results: v.array(memberSchema), +}); +export type MembersResponse = v.InferOutput; + +export async function members( + opts: Options, + apiOpts: ApiRequestOptions, +): Promise> { + const search = new URLSearchParams(); + if (opts.afterBlock) search.append("after_block", opts.afterBlock.toString()); + if (opts.unanchored) search.append("unanchored", "true"); + if (opts.limit) search.append("limit", opts.limit.toString()); + if (opts.offset) search.append("offset", opts.offset.toString()); + + const init: RequestInit = {}; + if (apiOpts.apiKeyConfig) { + init.headers = { + [apiOpts.apiKeyConfig.header]: apiOpts.apiKeyConfig.key, + }; + } + + const res = await fetch( + `${apiOpts.baseUrl}/extended/beta/stacking/${opts.poolPrincipal}/delegations?${search}`, + init, + ); + + if (!res.ok) { + return error({ + name: "FetchMembersError", + message: "Failed to fetch members.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonParseError, data] = await safePromise(res.json()); + if (jsonParseError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonParseError, + }); + } + + const validationResult = v.safeParse(membersResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/transactions/address-transactions.ts b/src/stacks-api/transactions/address-transactions.ts new file mode 100644 index 0000000..51e8ced --- /dev/null +++ b/src/stacks-api/transactions/address-transactions.ts @@ -0,0 +1,114 @@ +import { + error, + safePromise, + success, + type SafeError, + type Result as SafeResult, +} from "../../utils/safe.js"; +import { + baseListResponseSchema, + type ApiPaginationOptions, + type ApiRequestOptions, +} from "../types.js"; +import { transactionSchema } from "./schemas.js"; +import * as v from "valibot"; + +type Args = { + address: string; +} & ApiRequestOptions & + ApiPaginationOptions; + +// May be a good idea to move this to a shared location +const resultSchema = v.object({ + tx: transactionSchema, + stx_sent: v.string(), + stx_received: v.string(), + events: v.object({ + stx: v.object({ + transfer: v.number(), + mint: v.number(), + burn: v.number(), + }), + ft: v.object({ + transfer: v.number(), + mint: v.number(), + burn: v.number(), + }), + nft: v.object({ + transfer: v.number(), + mint: v.number(), + burn: v.number(), + }), + }), +}); +export type Result = v.InferOutput; + +const resultsSchema = v.array(resultSchema); +export type Results = v.InferOutput; + +const addressTransactionsResponseSchema = v.object({ + ...baseListResponseSchema.entries, + results: resultsSchema, +}); +export type AddressTransactionsResponse = v.InferOutput< + typeof addressTransactionsResponseSchema +>; + +export async function addressTransactions( + args: Args, +): Promise< + SafeResult< + AddressTransactionsResponse, + SafeError< + "FetchAddressTransactionsError" | "ParseBodyError" | "ValidateDataError" + > + > +> { + const search = new URLSearchParams(); + if (args.limit) search.append("limit", args.limit.toString()); + if (args.offset) search.append("offset", args.offset.toString()); + + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + + const res = await fetch( + `${args.baseUrl}/extended/v2/addresses/${args.address}/transactions?${search}`, + init, + ); + + if (!res.ok) { + return error({ + name: "FetchAddressTransactionsError", + message: "Failed to fetch address transactions.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.json()), + }, + }); + } + + const [jsonParseError, data] = await safePromise(res.json()); + if (jsonParseError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonParseError, + }); + } + + const validationResult = v.safeParse(addressTransactionsResponseSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + data: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/transactions/get-transaction.ts b/src/stacks-api/transactions/get-transaction.ts new file mode 100644 index 0000000..2634b83 --- /dev/null +++ b/src/stacks-api/transactions/get-transaction.ts @@ -0,0 +1,52 @@ +import { success, error, safePromise, type Result } from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; +import { transactionSchema, type Transaction } from "./schemas.js"; +import * as v from "valibot"; + +type Args = { + transactionId: string; +} & ApiRequestOptions; + +export async function getTransaction(args: Args): Promise> { + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + + const endpoint = `${args.baseUrl}/extended/v1/tx/${args.transactionId}`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchTransactionError", + message: `Failed to fetch transaction ${args.transactionId}`, + response: { + status: res.status, + statusText: res.statusText, + body: await safePromise(res.json()), + }, + }); + } + + const [jsonParseError, data] = await safePromise(res.json()); + if (jsonParseError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + error: jsonParseError, + }); + } + + const validationResult = v.safeParse(transactionSchema, data); + if (!validationResult.success) { + return error({ + name: "ValidateDataError", + message: "Failed to validate data.", + error: validationResult, + }); + } + + return success(validationResult.output); +} diff --git a/src/stacks-api/transactions/index.ts b/src/stacks-api/transactions/index.ts new file mode 100644 index 0000000..bfb0c3a --- /dev/null +++ b/src/stacks-api/transactions/index.ts @@ -0,0 +1,7 @@ +import { addressTransactions } from "./address-transactions.js"; +import { getTransaction } from "./get-transaction.js"; + +export const transactions = { + addressTransactions, + getTransaction, +}; diff --git a/src/stacks-api/transactions/schemas.ts b/src/stacks-api/transactions/schemas.ts new file mode 100644 index 0000000..8ebd94e --- /dev/null +++ b/src/stacks-api/transactions/schemas.ts @@ -0,0 +1,98 @@ +import * as v from "valibot"; + +export const baseTransactionSchema = v.object({ + tx_id: v.string(), + nonce: v.number(), + fee_rate: v.string(), + sender_address: v.string(), + sponsored: v.boolean(), + post_condition_mode: v.string(), + post_conditions: v.array(v.unknown()), + anchor_mode: v.string(), + is_unanchored: v.boolean(), + block_hash: v.string(), + parent_block_hash: v.string(), + block_height: v.number(), + block_time: v.number(), + block_time_iso: v.string(), + burn_block_height: v.number(), + burn_block_time: v.number(), + burn_block_time_iso: v.string(), + parent_burn_block_time: v.number(), + parent_burn_block_time_iso: v.string(), + canonical: v.boolean(), + tx_index: v.number(), + tx_status: v.string(), + tx_result: v.object({ + hex: v.string(), + repr: v.string(), + }), + microblock_hash: v.string(), + microblock_sequence: v.number(), + microblock_canonical: v.boolean(), + event_count: v.number(), + events: v.array(v.unknown()), + execution_cost_read_count: v.number(), + execution_cost_read_length: v.number(), + execution_cost_runtime: v.number(), + execution_cost_write_count: v.number(), + execution_cost_write_length: v.number(), +}); + +export const contractCallTransactionSchema = v.object({ + tx_type: v.literal("contract_call"), + contract_call: v.object({ + contract_id: v.string(), + function_name: v.string(), + function_signature: v.string(), + function_args: v.array( + v.object({ + hex: v.string(), + repr: v.string(), + name: v.string(), + type: v.string(), + }), + ), + }), + ...baseTransactionSchema.entries, +}); +export type ContractCallTransaction = v.InferOutput< + typeof contractCallTransactionSchema +>; + +export const smartContractTransactionSchema = v.object({ + tx_type: v.literal("smart_contract"), + smart_contract: v.object({ + /** + * NOTE: The types may be wrong, not sure what type of value is used when + * the version is not `null`. + */ + clarity_version: v.union([v.null(), v.number()]), + contract_id: v.string(), + source_code: v.string(), + }), + ...baseTransactionSchema.entries, +}); +export type SmartContractTransaction = v.InferOutput< + typeof smartContractTransactionSchema +>; + +export const tokenTransferSchema = v.object({ + tx_type: v.literal("token_transfer"), + token_transfer: v.object({ + recipient_address: v.string(), + amount: v.string(), + memo: v.string(), + }), + ...baseTransactionSchema.entries, +}); + +/** + * Incomplete schema of some transaction types. + */ +export const transactionSchema = v.variant("tx_type", [ + contractCallTransactionSchema, + smartContractTransactionSchema, + tokenTransferSchema, +]); +export type Transaction = v.InferOutput; diff --git a/src/stacks-api/types.ts b/src/stacks-api/types.ts new file mode 100644 index 0000000..ede3ca7 --- /dev/null +++ b/src/stacks-api/types.ts @@ -0,0 +1,31 @@ +import * as v from "valibot"; + +export type ApiKeyConfig = { + key: string; + header: string; +}; + +export type ApiRequestOptions = { + baseUrl: string; + apiKeyConfig?: ApiKeyConfig; +}; + +export type ApiPaginationOptions = { + /** + * The number of items to return. Each endpoint has its own maximum allowed + * limit, although many support at least 50 items. The [Hiro + * docs](https://docs.hiro.so/stacks/api) include the allowed maximum for each + * endpoint. + */ + limit?: number; + offset?: number; +}; + +export type ApiClientOptions = Partial; + +export const baseListResponseSchema = v.object({ + limit: v.number(), + offset: v.number(), + total: v.number(), + results: v.array(v.unknown()), +}); diff --git a/src/utils/call-rate-limited-api.ts b/src/utils/call-rate-limited-api.ts new file mode 100644 index 0000000..1f6dd1e --- /dev/null +++ b/src/utils/call-rate-limited-api.ts @@ -0,0 +1,40 @@ +import { error as safeError, type Result, type SafeError } from "./safe.js"; +import { backOff } from "exponential-backoff"; + +type Options = { + startingDelay?: number; + numOfAttempts?: number; +}; + +const defaultStartingDelay = 15_000; +const defaultNumOfAttempts = 5; + +export function callRateLimitedApi( + fn: () => Promise, + options?: Options, +): Promise { + return backOff(fn, { + startingDelay: options?.startingDelay ?? defaultStartingDelay, + numOfAttempts: options?.numOfAttempts ?? defaultNumOfAttempts, + }); +} + +export async function safeCallRateLimitedApi( + fn: () => Promise>, + options?: Options, +): Promise>> { + try { + return await backOff(() => fn(), { + startingDelay: options?.startingDelay ?? 15_000, + numOfAttempts: options?.numOfAttempts ?? 5, + }); + } catch (error) { + return safeError({ + name: "MaxRetriesExceeded", + message: "Failed to call rate limited API.", + data: { + error, + }, + }); + } +} diff --git a/src/utils/safe.ts b/src/utils/safe.ts new file mode 100644 index 0000000..e1e514f --- /dev/null +++ b/src/utils/safe.ts @@ -0,0 +1,45 @@ +export type SafeError = { + readonly name: TName; + readonly message: string; + readonly data?: TData; +}; + +export type Result = + | [E, null] + | [null, TData]; + +export function success(data: D): Result { + return [null, data]; +} + +export function error(error: E): Result { + return [error, null]; +} + +export async function safePromise( + promise: Promise, +): Promise>> { + try { + return success(await promise); + } catch (e) { + return error({ + name: "SafePromiseError", + message: "Safe promise rejected.", + data: e, + }); + } +} + +export function safeCall( + fn: () => Return, +): Result> { + try { + return success(fn()); + } catch (e) { + return error({ + name: "SafeCallError", + message: "Safe call failed.", + data: e, + }); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a594b34 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "NodeNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "outDir": "dist", + + // Bundler mode + "moduleResolution": "NodeNext", + "verbatimModuleSyntax": true, + "isolatedModules": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true, + + // If building with Typescript + "rootDir": "src", + "sourceMap": true, + "declaration": true + } +}