From afcc64009a117ef9896d4977e7476cd92d3df0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20Bardaj=C3=AD=20Puig?= Date: Mon, 2 Sep 2024 15:00:27 +0200 Subject: [PATCH] Initial commit --- .github/workflows/ci-main.yaml | 30 +++ .github/workflows/ci-pr.yaml | 46 +++++ .gitignore | 175 ++++++++++++++++++ README.md | 5 + bun.lockb | Bin 0 -> 23224 bytes package.json | 30 +++ src/index.ts | 2 + src/pox4-api/README.md | 29 +++ src/pox4-api/burn-height-to-reward-cycle.ts | 13 ++ src/pox4-api/current-pox-reward-cycle.ts | 1 + src/pox4-api/get-stacker-info.ts | 1 + src/pox4-api/reward-cycle-to-burn-height.ts | 1 + src/queries/get-signer-total-locked.ts | 71 +++++++ src/queries/index.ts | 5 + src/stacks-api/README.md | 20 ++ src/stacks-api/accounts/balances.ts | 102 ++++++++++ src/stacks-api/accounts/index.ts | 5 + src/stacks-api/blocks/get-block.ts | 89 +++++++++ src/stacks-api/blocks/index.ts | 5 + src/stacks-api/index.ts | 17 ++ src/stacks-api/info/core-api.ts | 64 +++++++ src/stacks-api/info/index.ts | 7 + src/stacks-api/info/pox-details.ts | 108 +++++++++++ src/stacks-api/proof-of-transfer/cycle.ts | 75 ++++++++ src/stacks-api/proof-of-transfer/cycles.ts | 76 ++++++++ src/stacks-api/proof-of-transfer/index.ts | 11 ++ .../proof-of-transfer/signers-in-cycle.ts | 97 ++++++++++ .../stackers-for-signer-in-cycle.ts | 101 ++++++++++ src/stacks-api/smart-contracts/index.ts | 5 + src/stacks-api/smart-contracts/read-only.ts | 72 +++++++ src/stacks-api/stacking-pool/index.ts | 5 + src/stacks-api/stacking-pool/members.ts | 84 +++++++++ .../transactions/address-transactions.ts | 114 ++++++++++++ .../transactions/get-transaction.ts | 52 ++++++ src/stacks-api/transactions/index.ts | 7 + src/stacks-api/transactions/schemas.ts | 98 ++++++++++ src/stacks-api/types.ts | 31 ++++ src/utils/call-rate-limited-api.ts | 40 ++++ src/utils/safe.ts | 45 +++++ tsconfig.json | 32 ++++ 40 files changed, 1771 insertions(+) create mode 100644 .github/workflows/ci-main.yaml create mode 100644 .github/workflows/ci-pr.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/pox4-api/README.md create mode 100644 src/pox4-api/burn-height-to-reward-cycle.ts create mode 100644 src/pox4-api/current-pox-reward-cycle.ts create mode 100644 src/pox4-api/get-stacker-info.ts create mode 100644 src/pox4-api/reward-cycle-to-burn-height.ts create mode 100644 src/queries/get-signer-total-locked.ts create mode 100644 src/queries/index.ts create mode 100644 src/stacks-api/README.md create mode 100644 src/stacks-api/accounts/balances.ts create mode 100644 src/stacks-api/accounts/index.ts create mode 100644 src/stacks-api/blocks/get-block.ts create mode 100644 src/stacks-api/blocks/index.ts create mode 100644 src/stacks-api/index.ts create mode 100644 src/stacks-api/info/core-api.ts create mode 100644 src/stacks-api/info/index.ts create mode 100644 src/stacks-api/info/pox-details.ts create mode 100644 src/stacks-api/proof-of-transfer/cycle.ts create mode 100644 src/stacks-api/proof-of-transfer/cycles.ts create mode 100644 src/stacks-api/proof-of-transfer/index.ts create mode 100644 src/stacks-api/proof-of-transfer/signers-in-cycle.ts create mode 100644 src/stacks-api/proof-of-transfer/stackers-for-signer-in-cycle.ts create mode 100644 src/stacks-api/smart-contracts/index.ts create mode 100644 src/stacks-api/smart-contracts/read-only.ts create mode 100644 src/stacks-api/stacking-pool/index.ts create mode 100644 src/stacks-api/stacking-pool/members.ts create mode 100644 src/stacks-api/transactions/address-transactions.ts create mode 100644 src/stacks-api/transactions/get-transaction.ts create mode 100644 src/stacks-api/transactions/index.ts create mode 100644 src/stacks-api/transactions/schemas.ts create mode 100644 src/stacks-api/types.ts create mode 100644 src/utils/call-rate-limited-api.ts create mode 100644 src/utils/safe.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..bb60c35e0ae0d9443b78dcec3ed733037e5e7b3e GIT binary patch literal 23224 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 bg8iswFu!g;;2=#&X+VuC@vhGQd;k9r#Y)Nc literal 0 HcmV?d00001 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 + } +}