diff --git a/.github/workflows/ci-pr.yaml b/.github/workflows/ci-pr.yaml index 61c41dc..ffc2f0c 100644 --- a/.github/workflows/ci-pr.yaml +++ b/.github/workflows/ci-pr.yaml @@ -43,16 +43,18 @@ jobs: run: | echo "@secretkeylabs:registry=https://registry.npmjs.org/" > .npmrc echo "//registry.npmjs.org/:_authToken=$AUTH_TOKEN" >> .npmrc - bunx npm@latest publish --access=public + bunx npm@latest publish --access=public --tag pr-$PR_NUMBER env: AUTH_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }} + PR_NUMBER: ${{ github.event.number }} - name: Publish to GitHub package registry # https://github.com/oven-sh/bun/issues/1976 run: | echo "@secretkeylabs:registry=https://npm.pkg.github.com/" > .npmrc echo "//npm.pkg.github.com/:_authToken=$AUTH_TOKEN" >> .npmrc - bunx npm@latest publish --access=public + bunx npm@latest publish --access=public --tag pr-$PR_NUMBER env: # https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-to-github-packages AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.number }} diff --git a/bun.lockb b/bun.lockb index 541c8a0..48ded81 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7fa385b..d1e1ac5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@secretkeylabs/stacks-tools", - "version": "0.5.0", + "version": "0.6.0", "type": "module", "files": [ "dist" @@ -25,11 +25,13 @@ "@arethetypeswrong/cli": "0.15.4", "@types/bun": "latest", "prettier": "^3.3.3", - "tsup": "^8.2.4" + "tsup": "^8.3.5", + "typescript": "^5.0.0" }, "peerDependencies": { - "typescript": "^5.0.0", - "valibot": "^0.41.0" + "@stacks/blockchain-api-client": "^8.2.1", + "@stacks/transactions": "^7.0.0", + "valibot": "^0.42.1" }, "dependencies": { "exponential-backoff": "3.1.1" diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..514dda4 --- /dev/null +++ b/src/README.md @@ -0,0 +1,44 @@ +# Clients + +Several clients are provided for requests to Stacks APIs and PoX managment. + +Types for `stacksApi` and `stacksRpcApi` are provided on a "best effort" basis. They are based on the types available or documented in: + +- https://github.com/hirosystems/stacks-blockchain-api +- https://github.com/hirosystems/docs + +The types change from time to time, either in value or location, and are ported here when needed. Help keep the types updated by opening a PR when type updates are needed. + +## API Client + +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 + +Not all endpoints have helpers currently, this is a work in progress. PRs adding new helpers are welcome. + +Each endpoint helper 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`. + +Types for responses are taken from `@stacks/blockchain-api-client`. Particularly, using its `OperationResponse` helper type and the operation name. + +### 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. It [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). Arguments for requests need to be provided in several places, such as the URL path, URL params, and payload, which is not very ergonomic. + +The methods provided here conveniently use a single config object and take care of constructing the request with the appropriate URL path and HTTP verb. + +## RPC API Client + +Follows the same pattern as the API Client described above. The types for responses are copied from available documentation and source code since they are not available as exports. + +## PoX4 API + +Contains helpers to call functions and read values from the PoX 4 contract. + +## Pool contract API + +Coming soon. Contains helpers to call functions and read values from the pool manager contract. diff --git a/src/index.ts b/src/index.ts index bc5c869..310d150 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,6 @@ export type * as StacksRpcApi from "./stacks-rpc-api/index.js"; export { queries } from "./queries/index.js"; export * from "./utils/index.js"; + +export { pox4Api } from "./pox4-api/index.js"; +export type * as Pox4Api from "./pox4-api/index.js"; diff --git a/src/pox4-api/constants.ts b/src/pox4-api/constants.ts new file mode 100644 index 0000000..96b35ca --- /dev/null +++ b/src/pox4-api/constants.ts @@ -0,0 +1,23 @@ +export type Network = "mainnet" | "testnet"; +export type ContractPrincipal = { address: string; name: string }; + +const netValueMap: Record< + Network, + { + pox4ContractAddress: string; + pox4ContractName: string; + } +> = { + mainnet: { + pox4ContractAddress: "SP000000000000000000002Q6VF78", + pox4ContractName: "pox-4", + }, + testnet: { + pox4ContractAddress: "ST000000000000000000002AMW42H", + pox4ContractName: "pox-4", + }, +}; + +export function networkDependentValues(network: Network) { + return netValueMap[network]; +} diff --git a/src/pox4-api/index.ts b/src/pox4-api/index.ts new file mode 100644 index 0000000..3bc78d8 --- /dev/null +++ b/src/pox4-api/index.ts @@ -0,0 +1,7 @@ +import { maps } from "./maps/index.js"; +export type * as Maps from "./maps/index.js"; + +import { readOnly } from "./read-only/index.js"; +export type * as ReadOnly from "./read-only/index.js"; + +export const pox4Api = { maps, readOnly }; diff --git a/src/pox4-api/maps/index.ts b/src/pox4-api/maps/index.ts new file mode 100644 index 0000000..36ef8d7 --- /dev/null +++ b/src/pox4-api/maps/index.ts @@ -0,0 +1,4 @@ +import { stackingState } from "./stacking-state.js"; +export type * as StackingState from "./stacking-state.js"; + +export const maps = { stackingState }; diff --git a/src/pox4-api/maps/stacking-state.ts b/src/pox4-api/maps/stacking-state.ts new file mode 100644 index 0000000..562f06c --- /dev/null +++ b/src/pox4-api/maps/stacking-state.ts @@ -0,0 +1,61 @@ +import { + cvToHex, + hexToCV, + type BufferCV, + type ListCV, + type OptionalCV, + type PrincipalCV, + type TupleCV, + type UIntCV, +} from "@stacks/transactions"; +import type { ApiRequestOptions, ProofAndTip } from "../../stacks-api/types.js"; +import { mapEntry } from "../../stacks-rpc-api/smart-contracts/map-entry.js"; +import { networkDependentValues, type Network } from "../constants.js"; +import { error, success, type Result } from "../../utils/safe.js"; + +export type StackingStateKey = TupleCV<{ stacker: PrincipalCV }>; +export type StackingStateValue = TupleCV<{ + "pox-addr": TupleCV<{ version: BufferCV; hashbytes: BufferCV }>; + "lock-period": UIntCV; + "first-reward-cycle": UIntCV; + "reward-set-indexes": ListCV; + "delegated-to": OptionalCV; +}>; + +export type Args = { + key: StackingStateKey; + network: Network; +} & ApiRequestOptions & + ProofAndTip; + +export async function stackingState({ + key, + network, + baseUrl, + apiKeyConfig, + proof, + tip, +}: Args): Promise> { + const [mapEntryError, mapEntryData] = await mapEntry({ + contractAddress: networkDependentValues(network).pox4ContractAddress, + contractName: networkDependentValues(network).pox4ContractName, + mapKey: cvToHex(key), + mapName: "stacking-state", + apiKeyConfig, + proof, + tip, + baseUrl, + }); + + if (mapEntryError) + return error({ + name: "FetchStackingStateError", + message: "Failed to fetch stacking state.", + data: mapEntryError, + }); + + return success({ + data: hexToCV(mapEntryData.data) as StackingStateValue, + proof: mapEntryData.proof, + }); +} diff --git a/src/pox4-api/public/index.ts b/src/pox4-api/public/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/pox4-api/read-only/common.ts b/src/pox4-api/read-only/common.ts new file mode 100644 index 0000000..0e28c04 --- /dev/null +++ b/src/pox4-api/read-only/common.ts @@ -0,0 +1,5 @@ +import type { OptionalCV, TupleCV, BufferCV } from "@stacks/transactions"; + +export type PoxAddr = OptionalCV< + TupleCV<{ version: BufferCV; hashbytes: BufferCV }> +>; diff --git a/src/pox4-api/read-only/get-check-delegation.ts b/src/pox4-api/read-only/get-check-delegation.ts new file mode 100644 index 0000000..243ab43 --- /dev/null +++ b/src/pox4-api/read-only/get-check-delegation.ts @@ -0,0 +1,64 @@ +import { + cvToHex, + hexToCV, + principalCV, + type OptionalCV, + type PrincipalCV, + type TupleCV, + type UIntCV, +} from "@stacks/transactions"; +import type { ApiRequestOptions } from "../../stacks-api/types.js"; +import { stacksRpcApi } from "../../stacks-rpc-api/index.js"; +import { error, success, type Result } from "../../utils/safe.js"; +import { type Network, networkDependentValues } from "../constants.js"; +import type { PoxAddr } from "./common.js"; + +type Args = { + principal: string; + network: Network; +} & ApiRequestOptions; + +export type GetCheckDelegationReturn = OptionalCV< + TupleCV<{ + "amount-ustx": UIntCV; + "delegated-to": PrincipalCV; + "until-burn-ht": OptionalCV; + "pox-addr": OptionalCV; + }> +>; + +export async function getCheckDelegation({ + principal, + network, + baseUrl, + apiKeyConfig, +}: Args): Promise> { + const [readOnlyError, readOnlyData] = + await stacksRpcApi.smartContracts.readOnly({ + contractAddress: networkDependentValues(network).pox4ContractAddress, + contractName: networkDependentValues(network).pox4ContractName, + functionName: "get-check-delegation", + arguments: [cvToHex(principalCV(principal))], + baseUrl, + apiKeyConfig, + sender: principal, + }); + + if (readOnlyError) { + return error({ + name: "GetCheckDelegationError", + message: "Failed to get check delegation.", + data: readOnlyError, + }); + } + + if (!readOnlyData.okay) { + return error({ + name: "GetCheckDelegationFunctionCallError", + message: "Call to `get-check-delegation` failed.", + data: readOnlyData, + }); + } + + return success(hexToCV(readOnlyData.result) as GetCheckDelegationReturn); +} diff --git a/src/pox4-api/read-only/get-stacker-info.ts b/src/pox4-api/read-only/get-stacker-info.ts new file mode 100644 index 0000000..305e1f3 --- /dev/null +++ b/src/pox4-api/read-only/get-stacker-info.ts @@ -0,0 +1,66 @@ +import { + cvToHex, + hexToCV, + principalCV, + type ListCV, + type OptionalCV, + type PrincipalCV, + type TupleCV, + type UIntCV, +} from "@stacks/transactions"; +import { stacksRpcApi } from "../../stacks-rpc-api/index.js"; +import { networkDependentValues, type Network } from "../constants.js"; +import type { ApiRequestOptions } from "../../stacks-api/types.js"; +import { error, success, type Result } from "../../utils/safe.js"; +import type { PoxAddr } from "./common.js"; + +export type Args = { + principal: string; + network: Network; +} & ApiRequestOptions; + +export type GetStackerInfoReturn = OptionalCV< + TupleCV<{ + "pox-addr": PoxAddr; + "lock-period": UIntCV; + "first-reward-cycle": UIntCV; + "reward-set-indexes": ListCV; + "delegated-to": OptionalCV; + }> +>; + +export async function getStackerInfo({ + principal, + network, + baseUrl, + apiKeyConfig, +}: Args): Promise> { + const [readOnlyError, readOnlyData] = + await stacksRpcApi.smartContracts.readOnly({ + contractAddress: networkDependentValues(network).pox4ContractAddress, + contractName: networkDependentValues(network).pox4ContractName, + functionName: "get-stacker-info", + arguments: [cvToHex(principalCV(principal))], + baseUrl, + apiKeyConfig, + sender: principal, + }); + + if (readOnlyError) { + return error({ + name: "GetStackerInfoError", + message: "Failed to get stacker info.", + data: readOnlyError, + }); + } + + if (!readOnlyData.okay) { + return error({ + name: "GetStackerInfoFunctionCallError", + message: "Call to `get-stacker-info` failed.", + data: readOnlyData, + }); + } + + return success(hexToCV(readOnlyData.result) as GetStackerInfoReturn); +} diff --git a/src/pox4-api/read-only/index.ts b/src/pox4-api/read-only/index.ts new file mode 100644 index 0000000..d9b266a --- /dev/null +++ b/src/pox4-api/read-only/index.ts @@ -0,0 +1,10 @@ +import { getStackerInfo } from "./get-stacker-info.js"; +export type * as GetStackerInfo from "./get-stacker-info.js"; + +import { getCheckDelegation } from "./get-check-delegation.js"; +export type * as GetCheckDelegation from "./get-check-delegation.js"; + +export const readOnly = { + getStackerInfo, + getCheckDelegation, +}; diff --git a/src/stacks-api/README.md b/src/stacks-api/README.md deleted file mode 100644 index 6e9ad5d..0000000 --- a/src/stacks-api/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# 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 index 5c9506e..35f5e80 100644 --- a/src/stacks-api/accounts/balances.ts +++ b/src/stacks-api/accounts/balances.ts @@ -1,3 +1,4 @@ +import type { OperationResponse } from "@stacks/blockchain-api-client"; import { error, safePromise, @@ -6,7 +7,6 @@ import { type SafeError, } from "../../utils/safe.js"; import type { ApiRequestOptions } from "../types.js"; -import * as v from "valibot"; export type Args = { principal: string; @@ -14,43 +14,11 @@ export type Args = { 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, + OperationResponse["get_account_balance"], SafeError<"FetchBalancesError" | "ParseBodyError" | "ValidateDataError"> > > { @@ -75,7 +43,7 @@ export async function balances( data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } @@ -89,14 +57,5 @@ export async function balances( }); } - const validationResult = v.safeParse(responseSchema, data); - if (!validationResult.success) { - return error({ - name: "ValidateDataError", - message: "Failed to validate data.", - data: validationResult, - }); - } - - return success(validationResult.output); + return success(data as OperationResponse["get_account_balance"]); } diff --git a/src/stacks-api/accounts/latest-nonce.ts b/src/stacks-api/accounts/latest-nonce.ts index 82b5432..bea6ec3 100644 --- a/src/stacks-api/accounts/latest-nonce.ts +++ b/src/stacks-api/accounts/latest-nonce.ts @@ -47,7 +47,7 @@ export async function latestNonce( endpoint, status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/blocks/get-block.ts b/src/stacks-api/blocks/get-block.ts index 05fe089..682b2ec 100644 --- a/src/stacks-api/blocks/get-block.ts +++ b/src/stacks-api/blocks/get-block.ts @@ -62,7 +62,7 @@ export async function getBlock( data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/faucets/stx.ts b/src/stacks-api/faucets/stx.ts index 474e2f6..253af6f 100644 --- a/src/stacks-api/faucets/stx.ts +++ b/src/stacks-api/faucets/stx.ts @@ -29,7 +29,7 @@ export async function stx(opts: Args): Promise> { data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/index.ts b/src/stacks-api/index.ts index 6c51566..57dd06a 100644 --- a/src/stacks-api/index.ts +++ b/src/stacks-api/index.ts @@ -19,11 +19,15 @@ export type * as StackingPool from "./stacking-pool/index.js"; import { transactions } from "./transactions/index.js"; export type * as Transactions from "./transactions/index.js"; +import { mempool } from "./mempool/index.js"; +export type * as Mempool from "./mempool/index.js"; + export const stacksApi = { accounts, blocks, faucets, info, + mempool, proofOfTransfer, stackingPool, transactions, diff --git a/src/stacks-api/info/core-api.ts b/src/stacks-api/info/core-api.ts index e4218cc..7dcce70 100644 --- a/src/stacks-api/info/core-api.ts +++ b/src/stacks-api/info/core-api.ts @@ -38,7 +38,7 @@ export async function coreApi( data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/info/index.ts b/src/stacks-api/info/index.ts index ae26d5e..ea8acc7 100644 --- a/src/stacks-api/info/index.ts +++ b/src/stacks-api/info/index.ts @@ -1,10 +1,6 @@ import { coreApi } from "./core-api.js"; export type * as CoreApi from "./core-api.js"; -import { poxDetails } from "./pox-details.js"; -export type * as 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 deleted file mode 100644 index 16e6ea0..0000000 --- a/src/stacks-api/info/pox-details.ts +++ /dev/null @@ -1,108 +0,0 @@ -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/mempool/index.ts b/src/stacks-api/mempool/index.ts new file mode 100644 index 0000000..e54d805 --- /dev/null +++ b/src/stacks-api/mempool/index.ts @@ -0,0 +1,6 @@ +import { transactionFeePriorities } from "./transaction-fee-priorities.js"; +export type * as TransactionFeePriorities from "./transaction-fee-priorities.js"; + +export const mempool = { + transactionFeePriorities, +}; diff --git a/src/stacks-api/mempool/transaction-fee-priorities.ts b/src/stacks-api/mempool/transaction-fee-priorities.ts new file mode 100644 index 0000000..916618d --- /dev/null +++ b/src/stacks-api/mempool/transaction-fee-priorities.ts @@ -0,0 +1,79 @@ +import { + error, + safePromise, + success, + type Result, + type SafeError, +} from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../types.js"; + +export type FeePrioritiesResponse = { + all: { + no_priority: number; + low_priority: number; + medium_priority: number; + high_priority: number; + }; + token_transfer: { + no_priority: number; + low_priority: number; + medium_priority: number; + high_priority: number; + }; + smart_contract: { + no_priority: number; + low_priority: number; + medium_priority: number; + high_priority: number; + }; + contract_call: { + no_priority: number; + low_priority: number; + medium_priority: number; + high_priority: number; + }; +}; + +export async function transactionFeePriorities( + opts: ApiRequestOptions, +): Promise< + Result< + FeePrioritiesResponse, + SafeError< + "FetchFeePrioritiesError" | "ParseBodyError" | "ValidateDataError" + > + > +> { + const init: RequestInit = {}; + if (opts.apiKeyConfig) { + init.headers = { + [opts.apiKeyConfig.header]: opts.apiKeyConfig.key, + }; + } + + const endpoint = `${opts.baseUrl}/extended/v2/mempool/fees`; + const res = await fetch(endpoint, init); + + if (!res.ok) { + return error({ + name: "FetchFeePrioritiesError", + message: "Failed to fetch transaction fee priorities.", + data: { + status: res.status, + statusText: res.statusText, + bodyParseResult: await safePromise(res.text()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonError, + }); + } + + return success(data as FeePrioritiesResponse); +} diff --git a/src/stacks-api/proof-of-transfer/cycle.ts b/src/stacks-api/proof-of-transfer/cycle.ts index 798ff36..1bede64 100644 --- a/src/stacks-api/proof-of-transfer/cycle.ts +++ b/src/stacks-api/proof-of-transfer/cycle.ts @@ -48,7 +48,7 @@ export async function cycle( endpoint, status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/proof-of-transfer/cycles.ts b/src/stacks-api/proof-of-transfer/cycles.ts index 86b20b8..1f588f9 100644 --- a/src/stacks-api/proof-of-transfer/cycles.ts +++ b/src/stacks-api/proof-of-transfer/cycles.ts @@ -49,7 +49,7 @@ export async function cycles(args: Args): Promise> { endpoint, status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/proof-of-transfer/signers-in-cycle.ts b/src/stacks-api/proof-of-transfer/signers-in-cycle.ts index 2d25461..cecd65f 100644 --- a/src/stacks-api/proof-of-transfer/signers-in-cycle.ts +++ b/src/stacks-api/proof-of-transfer/signers-in-cycle.ts @@ -67,7 +67,7 @@ export async function signersInCycle( endpoint, status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } 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 index f1c414c..93e39d4 100644 --- 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 @@ -74,7 +74,7 @@ export async function stackersForSignerInCycle( endpoint, status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/stacking-pool/members.ts b/src/stacks-api/stacking-pool/members.ts index ed0f84f..f03aeed 100644 --- a/src/stacks-api/stacking-pool/members.ts +++ b/src/stacks-api/stacking-pool/members.ts @@ -53,7 +53,7 @@ export async function members(args: Args): Promise> { data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/transactions/address-transactions.ts b/src/stacks-api/transactions/address-transactions.ts index 51e8ced..71501de 100644 --- a/src/stacks-api/transactions/address-transactions.ts +++ b/src/stacks-api/transactions/address-transactions.ts @@ -87,7 +87,7 @@ export async function addressTransactions( data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/transactions/get-transaction.ts b/src/stacks-api/transactions/get-transaction.ts index 2634b83..a8a283c 100644 --- a/src/stacks-api/transactions/get-transaction.ts +++ b/src/stacks-api/transactions/get-transaction.ts @@ -25,7 +25,7 @@ export async function getTransaction(args: Args): Promise> { response: { status: res.status, statusText: res.statusText, - body: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-api/transactions/index.ts b/src/stacks-api/transactions/index.ts index 39d3afb..d73072f 100644 --- a/src/stacks-api/transactions/index.ts +++ b/src/stacks-api/transactions/index.ts @@ -4,9 +4,13 @@ export type * as AddressTransactions from "./address-transactions.js"; import { getTransaction } from "./get-transaction.js"; export type * as GetTransaction from "./get-transaction.js"; +import { mempoolTransactions } from "./mempool-transactions.js"; +export type * as MempoolTransactions from "./mempool-transactions.js"; + export type * as Common from "./schemas.js"; export const transactions = { addressTransactions, getTransaction, + mempoolTransactions, }; diff --git a/src/stacks-api/transactions/mempool-transactions.ts b/src/stacks-api/transactions/mempool-transactions.ts new file mode 100644 index 0000000..54d5eb7 --- /dev/null +++ b/src/stacks-api/transactions/mempool-transactions.ts @@ -0,0 +1,101 @@ +import type { MempoolTransaction } from "@stacks/blockchain-api-client"; +import { + error, + safePromise, + success, + type SafeError, + type Result as SafeResult, +} from "../../utils/safe.js"; +import { + type ApiPaginationOptions, + type ApiRequestOptions, + type ListResponse, +} from "../types.js"; + +type Args = { + /** + * Filter to only return transactions with this sender address. + */ + senderAddress?: string; + + /** + * Filter to only return transactions with this recipient address (only + * applicable for STX transfer tx types). + */ + recipientAddress?: string; + + /** + * Filter to only return transactions with this address as the sender or + * recipient (recipient only applicable for STX transfer tx types). + */ + address?: string; + + /** + * Option to sort results by transaction age, size, or fee rate. + */ + orderBy?: "age" | "size" | "fee"; + + /** + * Option to sort results in ascending or descending order. + */ + order?: "asc" | "desc"; +} & ApiRequestOptions & + ApiPaginationOptions; + +export type MempoolTransactionsResponse = ListResponse; + +export async function mempoolTransactions( + args: Args, +): Promise< + SafeResult< + MempoolTransactionsResponse, + SafeError< + "FetchMempoolTransactionsError" | "ParseBodyError" | "ValidateDataError" + > + > +> { + const search = new URLSearchParams(); + if (args.limit) search.append("limit", args.limit.toString()); + if (args.offset) search.append("offset", args.offset.toString()); + if (args.senderAddress) search.append("sender_address", args.senderAddress); + if (args.recipientAddress) + search.append("recipient_address", args.recipientAddress); + if (args.address) search.append("address", args.address); + if (args.orderBy) search.append("order_by", args.orderBy); + if (args.order) search.append("order", args.order); + + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + + const res = await fetch( + `${args.baseUrl}/extended/v1/tx/mempool?${search}`, + init, + ); + + if (!res.ok) { + return error({ + name: "FetchMempoolTransactionsError", + message: "Failed to fetch mempool transactions.", + data: { + status: res.status, + statusText: res.statusText, + bodyText: await safePromise(res.text()), + }, + }); + } + + const [jsonParseError, data] = await safePromise(res.json()); + if (jsonParseError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body.", + data: jsonParseError, + }); + } + + return success(data as MempoolTransactionsResponse); +} diff --git a/src/stacks-api/types.ts b/src/stacks-api/types.ts index f5369aa..91ab152 100644 --- a/src/stacks-api/types.ts +++ b/src/stacks-api/types.ts @@ -41,3 +41,10 @@ export const baseListResponseSchema = v.object({ total: v.number(), results: v.array(v.unknown()), }); + +export type ListResponse = { + limit: number; + offset: number; + total: number; + results: T[]; +}; diff --git a/src/stacks-rpc-api/fees/estimate.ts b/src/stacks-rpc-api/fees/estimate.ts index b828176..7dd19ee 100644 --- a/src/stacks-rpc-api/fees/estimate.ts +++ b/src/stacks-rpc-api/fees/estimate.ts @@ -61,7 +61,7 @@ export async function estimate(args: Args): Promise> { status: res.status, statusText: res.statusText, endpoint, - bodyParseResult: await safePromise(res.text()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-rpc-api/index.ts b/src/stacks-rpc-api/index.ts index b57fe33..3bc9221 100644 --- a/src/stacks-rpc-api/index.ts +++ b/src/stacks-rpc-api/index.ts @@ -1,6 +1,10 @@ import { smartContracts } from "./smart-contracts/index.js"; export type * as SmartContracts from "./smart-contracts/index.js"; +import { pox } from "./pox/index.js"; +export type * as Pox from "./pox/index.js"; + export const stacksRpcApi = { + pox, smartContracts, }; diff --git a/src/stacks-rpc-api/pox/index.ts b/src/stacks-rpc-api/pox/index.ts new file mode 100644 index 0000000..da3001a --- /dev/null +++ b/src/stacks-rpc-api/pox/index.ts @@ -0,0 +1,6 @@ +import { poxDetails } from "./pox-details.js"; +export type * as PoxDetails from "./pox-details.js"; + +export const pox = { + poxDetails, +}; diff --git a/src/stacks-rpc-api/pox/pox-details.ts b/src/stacks-rpc-api/pox/pox-details.ts new file mode 100644 index 0000000..504e743 --- /dev/null +++ b/src/stacks-rpc-api/pox/pox-details.ts @@ -0,0 +1,189 @@ +import { error, safePromise, success, type Result } from "../../utils/safe.js"; +import type { ApiRequestOptions } from "../../stacks-api/types.js"; + +type Args = ApiRequestOptions; + +// This interface has been copied over from the documentation since it is not +// available as an export from existing libraries. +// https://github.com/hirosystems/docs/blob/main/content/docs/stacks/rpc-api/pox/pox-details.mdx +/** + * Get Proof of Transfer (PoX) information + */ +export interface CoreNodePoxResponse { + /** + * The contract identifier for the PoX contract + */ + contract_id: string; + /** + * The first burn block evaluated in this Stacks chain + */ + first_burnchain_block_height: number; + /** + * The latest Bitcoin chain block height + */ + current_burnchain_block_height: number; + /** + * The threshold of stacking participation that must be reached for PoX to + * activate in any cycle + */ + pox_activation_threshold_ustx: number; + /** + * The fraction of liquid STX that must vote to reject PoX in order to prevent + * the next reward cycle from activating. + */ + rejection_fraction: number; + /** + * The length in burn blocks of the reward phase + */ + reward_phase_block_length: number; + /** + * The length in burn blocks of the prepare phase + */ + prepare_phase_block_length: number; + /** + * The number of reward slots in a reward cycle + */ + reward_slots: number; + /** + * The current total amount of liquid microstacks. + */ + total_liquid_supply_ustx: number; + /** + * The length in burn blocks of a whole PoX cycle (reward phase and prepare + * phase) + */ + reward_cycle_length: number; + current_cycle: { + /** + * The reward cycle number + */ + id: number; + /** + * The threshold amount for obtaining a slot in this reward cycle. + */ + min_threshold_ustx: number; + /** + * The total amount of stacked microstacks in this reward cycle. + */ + stacked_ustx: number; + /** + * Whether or not PoX is active during this reward cycle. + */ + is_pox_active: boolean; + }; + next_cycle: { + /** + * The reward cycle number + */ + id: number; + /** + * The threshold amount for obtaining a slot in this reward cycle. + */ + min_threshold_ustx: number; + /** + * The total amount of stacked microstacks in this reward cycle. + */ + stacked_ustx: number; + /** + * The minimum amount that can be used to submit a `stack-stx` call. + */ + min_increment_ustx: number; + /** + * The burn block height when the prepare phase for this cycle begins. Any + * eligible stacks must be stacked before this block. + */ + prepare_phase_start_block_height: number; + /** + * The number of burn blocks until the prepare phase for this cycle starts. + * If the prepare phase for this cycle already started, this value will be + * negative. + */ + blocks_until_prepare_phase: number; + /** + * The burn block height when the reward phase for this cycle begins. Any + * eligible stacks must be stacked before this block. + */ + reward_phase_start_block_height: number; + /** + * The number of burn blocks until this reward phase starts. + */ + blocks_until_reward_phase: number; + /** + * The remaining amount of liquid STX that must vote to reject the next + * reward cycle to prevent the next reward cycle from activating. + */ + ustx_until_pox_rejection: number; + }; + /** + * @deprecated + * The active reward cycle number + */ + reward_cycle_id: number; + /** + * @deprecated + */ + min_amount_ustx: number; + /** + * @deprecated + */ + prepare_cycle_length: number; + /** + * @deprecated + */ + rejection_votes_left_required: number; + /** + * Versions of each PoX + */ + contract_versions: { + /** + * The contract identifier for the PoX contract + */ + contract_id: string; + /** + * The burn block height at which this version of PoX is activated + */ + activation_burnchain_block_height: number; + /** + * The first reward cycle number that uses this version of PoX + */ + first_reward_cycle_id: number; + }[]; +} + +export type PoxDetailsResponse = CoreNodePoxResponse; + +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, + bodyText: await safePromise(res.text()), + }, + }); + } + + const [jsonParseError, data] = await safePromise(res.json()); + if (jsonParseError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse pox details response.", + data: jsonParseError, + }); + } + + return success(data as PoxDetailsResponse); +} diff --git a/src/stacks-rpc-api/smart-contracts/index.ts b/src/stacks-rpc-api/smart-contracts/index.ts index 6726cb3..527ecf9 100644 --- a/src/stacks-rpc-api/smart-contracts/index.ts +++ b/src/stacks-rpc-api/smart-contracts/index.ts @@ -2,8 +2,11 @@ import { mapEntry } from "./map-entry.js"; export type * as MapEntry from "./map-entry.js"; import { readOnly } from "./read-only.js"; export type * as ReadOnly from "./read-only.js"; +import { contractInterface } from "./interface.js"; +export type * as ContractInterface from "./interface.js"; export const smartContracts = { + contractInterface, mapEntry, readOnly, }; diff --git a/src/stacks-rpc-api/smart-contracts/interface.ts b/src/stacks-rpc-api/smart-contracts/interface.ts new file mode 100644 index 0000000..ec07d84 --- /dev/null +++ b/src/stacks-rpc-api/smart-contracts/interface.ts @@ -0,0 +1,53 @@ +import type { ClarityAbi } from "@stacks/transactions"; +import type { ApiRequestOptions, ProofAndTip } from "../../stacks-api/types.js"; +import { error, safePromise, success, type Result } from "../../utils/safe.js"; + +export type Args = { + contractAddress: string; + contractName: string; +} & ApiRequestOptions & + ProofAndTip; + +export type InterfaceResponse = ClarityAbi; + +export async function contractInterface( + args: Args, +): Promise> { + const search = new URLSearchParams(); + if (args.proof === 0) search.append("proof", "0"); + if (args.tip) search.append("tip", args.tip); + + const init: RequestInit = {}; + if (args.apiKeyConfig) { + init.headers = { + [args.apiKeyConfig.header]: args.apiKeyConfig.key, + }; + } + + const endpoint = `${args.baseUrl}/v2/contracts/interface/${args.contractAddress}/${args.contractName}`; + const res = await fetch(endpoint, init); + if (!res.ok) { + return error({ + name: "FetcContractInterfaceError", + message: "Failed to fetch contract interface.", + data: { + init, + status: res.status, + statusText: res.statusText, + endpoint, + bodyText: await safePromise(res.text()), + }, + }); + } + + const [jsonError, data] = await safePromise(res.json()); + if (jsonError) { + return error({ + name: "ParseBodyError", + message: "Failed to parse response body as JSON.", + data: jsonError, + }); + } + + return success(data as InterfaceResponse); +} diff --git a/src/stacks-rpc-api/smart-contracts/map-entry.ts b/src/stacks-rpc-api/smart-contracts/map-entry.ts index 222504f..8485723 100644 --- a/src/stacks-rpc-api/smart-contracts/map-entry.ts +++ b/src/stacks-rpc-api/smart-contracts/map-entry.ts @@ -54,7 +54,7 @@ export async function mapEntry(args: Args): Promise> { status: res.status, statusText: res.statusText, endpoint, - bodyParseResult: await safePromise(res.text()), + bodyText: await safePromise(res.text()), }, }); } diff --git a/src/stacks-rpc-api/smart-contracts/read-only.ts b/src/stacks-rpc-api/smart-contracts/read-only.ts index fa10939..59ec72c 100644 --- a/src/stacks-rpc-api/smart-contracts/read-only.ts +++ b/src/stacks-rpc-api/smart-contracts/read-only.ts @@ -1,6 +1,5 @@ import { error, safePromise, success, type Result } from "../../utils/safe.js"; import type { ApiRequestOptions } from "../../stacks-api/types.js"; -import * as v from "valibot"; export type Args = { sender: string; @@ -10,20 +9,22 @@ export type Args = { functionName: string; } & ApiRequestOptions; -export const readOnlyResponseSchema = v.variant("okay", [ - v.object({ - okay: v.literal(true), - /** - * A Clarity value as a hex-encoded string. - */ - result: v.string(), - }), - v.object({ - okay: v.literal(false), - cause: v.unknown(), - }), -]); -export type ReadOnlyResponse = v.InferOutput; +// These interfaces have been copied over from source since they are not +// available from existing libraries. +// https://github.com/hirosystems/stacks-blockchain-api/blob/f0176a038b3c4fde35195bbfbf9b6d8d5504c9bb/src/core-rpc/client.ts#L94-L106 +interface ReadOnlyContractCallSuccessResponse { + okay: true; + result: string; +} +interface ReadOnlyContractCallFailResponse { + okay: false; + cause: string; +} +export type ReadOnlyContractCallResponse = + | ReadOnlyContractCallSuccessResponse + | ReadOnlyContractCallFailResponse; + +export type ReadOnlyResponse = ReadOnlyContractCallResponse; export async function readOnly(args: Args): Promise> { const headers: Record = { @@ -53,7 +54,7 @@ export async function readOnly(args: Args): Promise> { data: { status: res.status, statusText: res.statusText, - bodyParseResult: await safePromise(res.json()), + bodyText: await safePromise(res.text()), }, }); } @@ -67,14 +68,5 @@ export async function readOnly(args: Args): Promise> { }); } - const validationResult = v.safeParse(readOnlyResponseSchema, data); - if (!validationResult.success) { - return error({ - name: "ValidateDataError", - message: "Failed to validate data.", - data: validationResult, - }); - } - - return success(validationResult.output); + return success(data as ReadOnlyResponse); }