diff --git a/.github/MIGRATION.md b/.github/MIGRATION.md index 812fc00af..c93faec50 100644 --- a/.github/MIGRATION.md +++ b/.github/MIGRATION.md @@ -1,7 +1,11 @@ # Migration Guides -- [Stacks.js (\<=4.x.x) → (5.x.x)](#stacksjs-4xx--5xx) +- [Stacks.js (\>=5.x.x) → (7.x.x)](#stacksjs-5xx--7xx) - [Breaking Changes](#breaking-changes) + - [StacksNodeApi](#stacksnodeapi) + - [StacksNetwork to StacksNodeApi](#stacksnetwork-to-stacksnodeapi) +- [Stacks.js (\<=4.x.x) → (5.x.x)](#stacksjs-4xx--5xx) + - [Breaking Changes](#breaking-changes-1) - [Buffer to Uint8Array](#buffer-to-uint8array) - [Message Signing Prefix](#message-signing-prefix) - [blockstack.js → Stacks.js (1.x.x)](#blockstackjs--stacksjs-1xx) @@ -14,6 +18,84 @@ - [Using blockstack.js](#using-blockstackjs-2) - [Using @stacks/encryption or @stacks/auth](#using-stacksencryption-or-stacksauth) +## Stacks.js (>=5.x.x) → (7.x.x) + +### Breaking Changes + +- The `@stacks/network` package was removed. Similar functionality is now available in `@stacks/transactions`. [Read more...](#stacksnetwork-to-stacksnodeapi) + +### StacksNodeApi + +The new `StacksNodeApi` class lets you interact with a Stacks node or API. + + + +```ts +import { StacksNodeApi } from '@stacks/transactions'; + +const api = new StacksNodeApi(); +await api.broadcastTx(txHex); +``` + +### StacksNetwork to StacksNodeApi + +Stacks network objects are now exported by the `@stacks/common` package. +They are used to specify network settings for other functions and don't require instantiation (like the `@stacks/network` approach did). + +```ts +import { STACKS_MAINNET } from '@stacks/transactions'; +``` + +After importing the network object (e.g. `STACKS_MAINNET` here), you can use it in other functions like so: + +```ts +// todo: update more functions, show example +``` + +For easing the transition, the functions which depended on the networking aspect of `@stacks/network` now accept an `api` parameter. +The `api` parameter can be an instance of `StacksNodeApi` or any object containing a `url` and `fetch` property. + +- The `url` property should be a string containing the base URL of the Stacks node you want to use. +- The `fetch` property can be any (fetch)[https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API] compatible function. + +The following diffs show examples of how to migrate to the new pattern. + +```diff +import { makeSTXTokenTransfer } from '@stacks/transactions'; + +- import { StacksTestnet } from '@stacks/network'; ++ import { STACKS_TESTNET } from '@stacks/transactions'; + +const transaction = await makeSTXTokenTransfer({ + // ... +- network: new StacksTestnet(), ++ network: STACKS_TESTNET, +}); +``` + +> [!NOTE] +> String literal network names are still supported. + +```diff +const transaction = await makeSTXTokenTransfer({ + // ... +- network: new StacksTestnet(), ++ network: 'testnet', +}); +``` + +> [!NOTE] +> Custom URLs and fetch functions are still supported via the `api` parameter. + +```diff +const transaction = await makeSTXTokenTransfer({ + // ... +- network: new StacksTestnet({ url: "mynode-optional.com", fetchFn: myFetch }), // optional options ++ network: STACKS_TESTNET, ++ api: { url: "mynode-optional.com", fetch: myFetch } // optional params +}); +``` + ## Stacks.js (<=4.x.x) → (5.x.x) ### Breaking Changes diff --git a/.vscode/launch.json b/.vscode/launch.json index 7dee9d110..981715e7e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,9 +7,7 @@ "name": "Jest: auth", "cwd": "${workspaceFolder}/packages/auth", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -18,9 +16,7 @@ "name": "Jest: bns", "cwd": "${workspaceFolder}/packages/bns", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -29,9 +25,7 @@ "name": "Jest: cli", "cwd": "${workspaceFolder}/packages/cli", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -40,9 +34,7 @@ "name": "Jest: common", "cwd": "${workspaceFolder}/packages/common", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -51,9 +43,7 @@ "name": "Jest: encryption", "cwd": "${workspaceFolder}/packages/encryption", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -62,20 +52,16 @@ "name": "Jest: keychain", "cwd": "${workspaceFolder}/packages/keychain", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { "type": "node", "request": "launch", - "name": "Jest: network", - "cwd": "${workspaceFolder}/packages/network", + "name": "Jest: api", + "cwd": "${workspaceFolder}/packages/api", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -84,9 +70,7 @@ "name": "Jest: profile", "cwd": "${workspaceFolder}/packages/profile", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -95,9 +79,7 @@ "name": "Jest: stacking", "cwd": "${workspaceFolder}/packages/stacking", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -106,9 +88,7 @@ "name": "Jest: storage", "cwd": "${workspaceFolder}/packages/storage", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" }, { @@ -117,10 +97,8 @@ "name": "Jest: transactions", "cwd": "${workspaceFolder}/packages/transactions", "program": "./node_modules/.bin/jest", - "args": [ - "--runInBand" - ], + "args": ["--runInBand"], "console": "integratedTerminal" - }, + } ] } diff --git a/README.md b/README.md index 364b84f36..031811aa8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Stacks.js [![Test Action Badge](https://github.com/hirosystems/stacks.js/actions/workflows/tests.yml/badge.svg)](https://github.com/hirosystems/stacks.js/actions/workflows/tests.yml) [![Monorepo Version Label](https://img.shields.io/github/lerna-json/v/hirosystems/stacks.js?label=monorepo)](https://github.com/hirosystems/stacks.js/tree/main/packages) - - Welcome to the Stacks.js repository, your one-stop solution for working with the Stacks blockchain using JavaScript/TypeScript. This repository nests a collection of packages designed to provide you with the essential building blocks to work with the [Stacks blockchain](https://www.stacks.co/what-is-stacks) from JavaScript/TypeScript. ## Packages @@ -20,7 +18,7 @@ For installation instructions and usage guidelines, refer to the respective `REA - [`@stacks/encryption`](https://github.com/hirosystems/stacks.js/tree/main/packages/encryption) Encryption functions used by stacks.js packages. - [`@stacks/auth`](https://github.com/hirosystems/stacks.js/tree/main/packages/auth) Construct and decode authentication requests for Stacks apps. - [`@stacks/profile`](https://github.com/hirosystems/stacks.js/tree/main/packages/profile) Functions for manipulating user profiles. -- [`@stacks/network`](https://github.com/hirosystems/stacks.js/tree/main/packages/network) Network and API library for working with Stacks blockchain nodes. +- [`@stacks/api`](https://github.com/hirosystems/stacks.js/tree/main/packages/api) Stacks API and node library for network operations. - [`@stacks/common`](https://github.com/hirosystems/stacks.js/tree/main/packages/common) Common utilities used by stacks.js packages. ### Native Smart Contract Interaction @@ -45,9 +43,9 @@ To migrate your app from blockstack.js to Stacks.js follow the steps in the [mig If you encounter a bug or have a feature request, we encourage you to follow the steps below: -1. **Search for existing issues:** Before submitting a new issue, please search [existing and closed issues](../../issues) to check if a similar problem or feature request has already been reported. -1. **Open a new issue:** If it hasn't been addressed, please [open a new issue](../../issues/new/choose). Choose the appropriate issue template and provide as much detail as possible, including steps to reproduce the bug or a clear description of the requested feature. -1. **Evaluation SLA:** Our team reads and evaluates all the issues and pull requests. We are available Monday to Friday and we make our best effort to respond within 7 business days. +1. **Search for existing issues:** Before submitting a new issue, please search [existing and closed issues](../../issues) to check if a similar problem or feature request has already been reported. +1. **Open a new issue:** If it hasn't been addressed, please [open a new issue](../../issues/new/choose). Choose the appropriate issue template and provide as much detail as possible, including steps to reproduce the bug or a clear description of the requested feature. +1. **Evaluation SLA:** Our team reads and evaluates all the issues and pull requests. We are available Monday to Friday and we make our best effort to respond within 7 business days. Please **do not** use the issue tracker for personal support requests or to ask for the status of a transaction. You'll find help at the [#support Discord channel](https://discord.gg/SK3DxdsP). @@ -56,9 +54,11 @@ Please **do not** use the issue tracker for personal support requests or to ask Development of Stacks.js happens in the open on GitHub, and we are grateful to the community for contributing bug fixes and improvements. Read below to learn how you can take part in improving the Stacks.js. ### Code of Conduct -Please read Stacks.js' [Code of Conduct](https://github.com/hirosystems/stacks.js/blob/main/CODE_OF_CONDUCT.md) since we expect project participants to adhere to it. + +Please read Stacks.js' [Code of Conduct](https://github.com/hirosystems/stacks.js/blob/main/CODE_OF_CONDUCT.md) since we expect project participants to adhere to it. ### Contributing Guide + Read our [contributing guide](https://github.com/hirosystems/stacks.js/blob/main/.github/CONTRIBUTING.md) to learn about our development process, how to propose bug fixes and improvements, and how to build and test your changes. ## Community @@ -66,9 +66,7 @@ Read our [contributing guide](https://github.com/hirosystems/stacks.js/blob/main Join our community and stay connected with the latest updates and discussions: - [Join our Discord community chat](https://discord.gg/ZQR6cyZC) to engage with other users, ask questions, and participate in discussions. - - [Visit hiro.so](https://www.hiro.so/) for updates and subscribe to the mailing list. - - Follow [Hiro on Twitter.](https://twitter.com/hirosystems) ## License diff --git a/configs/webpack.config.js b/configs/webpack.config.js index cb9ebdcba..ce45db998 100644 --- a/configs/webpack.config.js +++ b/configs/webpack.config.js @@ -54,12 +54,12 @@ module.exports = { resolve: { extensions: ['.ts', '.js'], alias: { + '@stacks/api': '@stacks/api/dist/esm', '@stacks/auth': '@stacks/auth/dist/esm', '@stacks/bns': '@stacks/bns/dist/esm', '@stacks/common': '@stacks/common/dist/esm', '@stacks/encryption': '@stacks/encryption/dist/esm', '@stacks/keychain': '@stacks/keychain/dist/esm', - '@stacks/network': '@stacks/network/dist/esm', '@stacks/profile': '@stacks/profile/dist/esm', '@stacks/stacking': '@stacks/stacking/dist/esm', '@stacks/storage': '@stacks/storage/dist/esm', diff --git a/package-lock.json b/package-lock.json index 645cb163d..519468a11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4308,6 +4308,10 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" }, + "node_modules/@stacks/api": { + "resolved": "packages/api", + "link": true + }, "node_modules/@stacks/auth": { "resolved": "packages/auth", "link": true @@ -4365,10 +4369,6 @@ "eslint-plugin-unused-imports": ">=3" } }, - "node_modules/@stacks/network": { - "resolved": "packages/network", - "link": true - }, "node_modules/@stacks/prettier-config": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/@stacks/prettier-config/-/prettier-config-0.0.10.tgz", @@ -23606,14 +23606,41 @@ "node": ">=10" } }, + "packages/api": { + "name": "@stacks/api", + "version": "6.9.0", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.8.1", + "@stacks/transactions": "^6.9.0" + }, + "devDependencies": { + "rimraf": "^3.0.2" + } + }, + "packages/api/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/auth": { "name": "@stacks/auth", "version": "6.9.0", "license": "MIT", "dependencies": { + "@stacks/api": "^6.8.1", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", "@stacks/profile": "^6.9.0", "cross-fetch": "^3.1.5", "jsontokens": "^4.0.1" @@ -23642,8 +23669,8 @@ "version": "6.9.0", "license": "MIT", "dependencies": { + "@stacks/api": "^6.8.1", "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", "@stacks/transactions": "^6.9.0" }, "devDependencies": { @@ -23672,12 +23699,12 @@ "dependencies": { "@scure/bip32": "1.1.3", "@scure/bip39": "1.1.0", + "@stacks/api": "^6.8.1", "@stacks/auth": "^6.9.0", "@stacks/blockchain-api-client": "4.0.1", "@stacks/bns": "^6.9.0", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", "@stacks/stacking": "^6.9.0", "@stacks/storage": "^6.9.0", "@stacks/transactions": "^6.9.0", @@ -23742,7 +23769,8 @@ "license": "MIT", "dependencies": { "@types/bn.js": "^5.1.0", - "@types/node": "^18.0.4" + "@types/node": "^18.0.4", + "cross-fetch": "^3.1.5" }, "devDependencies": { "bn.js": "^5.2.1", @@ -23824,6 +23852,7 @@ "packages/network": { "name": "@stacks/network", "version": "6.8.1", + "extraneous": true, "license": "MIT", "dependencies": { "@stacks/common": "^6.8.1", @@ -23834,27 +23863,13 @@ "rimraf": "^3.0.2" } }, - "packages/network/node_modules/rimraf": { - "version": "3.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "packages/profile": { "name": "@stacks/profile", "version": "6.9.0", "license": "MIT", "dependencies": { + "@stacks/api": "^6.8.1", "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", "@stacks/transactions": "^6.9.0", "jsontokens": "^4.0.1", "schema-inspector": "^2.0.2", @@ -23886,9 +23901,9 @@ "license": "MIT", "dependencies": { "@scure/base": "1.1.1", + "@stacks/api": "^6.8.1", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", "@stacks/stacks-blockchain-api-types": "^0.61.0", "@stacks/transactions": "^6.9.0", "bs58": "^5.0.0" @@ -24032,10 +24047,10 @@ "version": "6.9.0", "license": "MIT", "dependencies": { + "@stacks/api": "^6.8.1", "@stacks/auth": "^6.9.0", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", "base64-js": "^1.5.1", "jsontokens": "^4.0.1" }, @@ -24117,9 +24132,10 @@ "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", + "@stacks/api": "^6.8.1", "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", "c32check": "^2.0.0", + "cross-fetch": "^3.1.5", "lodash.clonedeep": "^4.5.0" }, "devDependencies": { @@ -24154,10 +24170,10 @@ "dependencies": { "@scure/bip32": "1.1.3", "@scure/bip39": "1.1.0", + "@stacks/api": "^6.8.1", "@stacks/auth": "^6.9.0", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", "@stacks/profile": "^6.9.0", "@stacks/storage": "^6.9.0", "@stacks/transactions": "^6.9.0", diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 000000000..d372277e9 --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,15 @@ +# @stacks/api [![npm](https://img.shields.io/npm/v/@stacks/api?color=red)](https://www.npmjs.com/package/@stacks/api) + +todo: one-liner + +## Installation + +``` +npm install @stacks/api +``` + +## Overview + +todo + +## Todo diff --git a/packages/network/jest.config.js b/packages/api/jest.config.js similarity index 100% rename from packages/network/jest.config.js rename to packages/api/jest.config.js diff --git a/packages/network/package.json b/packages/api/package.json similarity index 82% rename from packages/network/package.json rename to packages/api/package.json index 9fad58f7a..779ee6ce3 100644 --- a/packages/network/package.json +++ b/packages/api/package.json @@ -1,9 +1,12 @@ { - "name": "@stacks/network", - "version": "6.8.1", - "description": "Library for Stacks network operations", + "name": "@stacks/api", + "version": "6.9.0", + "description": "Javascript library for interacting with the Stacks Blockchain Node and API.", "license": "MIT", "author": "Hiro Systems PBC (https://hiro.so)", + "contributors": [ + "janniks" + ], "homepage": "https://hiro.so/stacks-js", "scripts": { "build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:umd", @@ -21,10 +24,9 @@ }, "dependencies": { "@stacks/common": "^6.8.1", - "cross-fetch": "^3.1.5" + "@stacks/transactions": "^6.9.0" }, "devDependencies": { - "process": "^0.11.10", "rimraf": "^3.0.2" }, "sideEffects": false, @@ -40,16 +42,11 @@ "dist", "src" ], - "keywords": [ - "blockstack", - "network", - "stacks" - ], "repository": { "type": "git", "url": "git+https://github.com/hirosystems/stacks.js.git" }, "bugs": { - "url": "https://github.com/blockstack/blockstack.js/issues" + "url": "https://github.com/hirosystems/stacks.js/issues" } } diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts new file mode 100644 index 000000000..c9e0fe8f1 --- /dev/null +++ b/packages/api/src/api.ts @@ -0,0 +1,139 @@ +import { + DEVNET_URL, + FetchFn, + HIRO_MAINNET_URL, + HIRO_TESTNET_URL, + Hex, + createFetchFn, +} from '@stacks/common'; +import { + ClarityAbi, + FeeEstimation, + STACKS_MAINNET, + StacksNetwork, + StacksNetworkName, + StacksTransaction, + TransactionVersion, + TxBroadcastResult, + broadcastTransaction, + estimateTransaction, + getAbi, + getNonce, + networkFrom, +} from '@stacks/transactions'; + +export class StacksNodeApi { + // TODO + bnsLookupUrl = 'https://stacks-node-api.mainnet.stacks.co'; + + public url: string; + public fetch: FetchFn; + + public network: StacksNetwork; + + constructor({ + url, + fetch, + network = STACKS_MAINNET, + }: { + /** The base API/node URL for the network fetch calls */ + url?: string; + /** Stacks network object (defaults to {@link STACKS_MAINNET}) */ + network?: StacksNetworkName | StacksNetwork; + /** An optional custom fetch function to override default behaviors */ + fetch?: FetchFn; + } = {}) { + this.url = url ?? deriveDefaultUrl(network); + this.fetch = fetch ?? createFetchFn(); + this.network = networkFrom(network); + } + + /** Returns `true` if the network is configured to 'mainnet', based on the TransactionVersion */ + isMainnet = () => this.network.transactionVersion === TransactionVersion.Mainnet; + + /** + * Broadcast a serialized transaction to a Stacks node (which will validate and forward to the network). + * @param transaction - The transaction to broadcast + * @param attachment - Optional attachment to include with the transaction + * @returns a Promise that resolves to a {@link TxBroadcastResult} object + */ + broadcastTransaction = async ( + transaction: StacksTransaction, + attachment?: Uint8Array | string + ): Promise => { + // todo: should we use a opts object instead of positional args here? + return broadcastTransaction({ transaction, attachment, api: this }); + }; + + /** + * Lookup the nonce for an address from a core node + * @param address - The Stacks address to look up the next nonce for + * @return A promise that resolves to a bigint of the next nonce + */ + getNonce = async (address: string): Promise => { + return getNonce({ address, api: this }); + }; + + /** + * Estimate the total transaction fee in microstacks for a Stacks transaction + * @param payload - The transaction to estimate fees for + * @param estimatedLength - Optional argument that provides the endpoint with an + * estimation of the final length (in bytes) of the transaction, including any post-conditions + * and signatures + * @return A promise that resolves to an array of {@link FeeEstimate} + */ + estimateTransaction = async ( + payload: Hex, + estimatedLength?: number + ): Promise<[FeeEstimation, FeeEstimation, FeeEstimation]> => { + return estimateTransaction({ payload, estimatedLength, api: this }); + }; + + /** + * Fetch a contract's ABI + * @param contractAddress - The contracts address + * @param contractName - The contracts name + * @returns A promise that resolves to a ClarityAbi if the operation succeeds + */ + getAbi = async (contractAddress: string, contractName: string): Promise => { + return getAbi({ contractAddress, contractName, api: this }); + }; + + // todo: migrate to new api pattern + getNameInfo(fullyQualifiedName: string) { + /* + TODO: Update to v2 API URL for name lookups + */ + const nameLookupURL = `${this.bnsLookupUrl}/v1/names/${fullyQualifiedName}`; + return this.fetch(nameLookupURL) + .then((resp: any) => { + if (resp.status === 404) { + throw new Error('Name not found'); + } else if (resp.status !== 200) { + throw new Error(`Bad response status: ${resp.status}`); + } else { + return resp.json(); + } + }) + .then((nameInfo: any) => { + // the returned address _should_ be in the correct network --- + // stacks node gets into trouble because it tries to coerce back to mainnet + // and the regtest transaction generation libraries want to use testnet addresses + if (nameInfo.address) { + return Object.assign({}, nameInfo, { address: nameInfo.address }); + } else { + return nameInfo; + } + }); + } +} + +export function deriveDefaultUrl(network: StacksNetwork | StacksNetworkName) { + network = networkFrom(network); + + return !network || network.transactionVersion === TransactionVersion.Mainnet + ? HIRO_MAINNET_URL // default to mainnet if no network is given or txVersion is mainnet + : network.magicBytes === 'id' + ? DEVNET_URL // default to devnet if magicBytes are devnet + : HIRO_TESTNET_URL; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 000000000..b1c13e734 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/packages/api/tests/api.test.ts b/packages/api/tests/api.test.ts new file mode 100644 index 000000000..b7336c487 --- /dev/null +++ b/packages/api/tests/api.test.ts @@ -0,0 +1,22 @@ +import { DEVNET_URL, HIRO_MAINNET_URL, HIRO_TESTNET_URL } from '@stacks/common'; +import { StacksNodeApi } from '../src/api'; +import { STACKS_DEVNET, STACKS_MAINNET, STACKS_TESTNET } from '../src'; + +describe('setting StacksApi URL', () => { + test.each([ + { network: STACKS_MAINNET, url: HIRO_MAINNET_URL }, + { network: STACKS_TESTNET, url: HIRO_TESTNET_URL }, + { network: STACKS_DEVNET, url: DEVNET_URL }, + ])('the api class determines the correct url for each network object', ({ network, url }) => { + const api = new StacksNodeApi({ network }); + expect(api.url).toEqual(url); + }); +}); + +// todo: still needed? +// it('uses the correct constructor for stacks network from name strings', () => { +// expect(StacksNetwork.fromName('mainnet').constructor.toString()).toContain('StacksMainnet'); +// expect(StacksNetwork.fromName('testnet').constructor.toString()).toContain('StacksTestnet'); +// expect(StacksNetwork.fromName('devnet').constructor.toString()).toContain('StacksMocknet'); // devnet is an alias for mocknet +// expect(StacksNetwork.fromName('mocknet').constructor.toString()).toContain('StacksMocknet'); +// }); diff --git a/packages/network/tests/setup.js b/packages/api/tests/setup.js similarity index 60% rename from packages/network/tests/setup.js rename to packages/api/tests/setup.js index 21139b98e..88c4e7b1b 100644 --- a/packages/network/tests/setup.js +++ b/packages/api/tests/setup.js @@ -1,2 +1,2 @@ const fetchMock = require('jest-fetch-mock'); -fetchMock.enableFetchMocks(); +fetchMock.enableFetchMocks(); \ No newline at end of file diff --git a/packages/network/tsconfig.build.json b/packages/api/tsconfig.build.json similarity index 100% rename from packages/network/tsconfig.build.json rename to packages/api/tsconfig.build.json diff --git a/packages/network/tsconfig.json b/packages/api/tsconfig.json similarity index 73% rename from packages/network/tsconfig.json rename to packages/api/tsconfig.json index 006aa1705..ca7a34f43 100644 --- a/packages/network/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020" + }, "include": ["src/**/*", "tests/**/*"], "typedocOptions": { "entryPoints": ["src/index.ts"] diff --git a/packages/network/webpack.config.js b/packages/api/webpack.config.js similarity index 71% rename from packages/network/webpack.config.js rename to packages/api/webpack.config.js index 5176d4fb5..93e69b8ab 100644 --- a/packages/network/webpack.config.js +++ b/packages/api/webpack.config.js @@ -1,6 +1,6 @@ const config = require('../../configs/webpack.config.js'); -config.output.library.name = 'StacksNetwork'; +config.output.library.name = 'StacksApi'; config.resolve.fallback = {}; diff --git a/packages/auth/package.json b/packages/auth/package.json index 572e540e0..e09449416 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -22,7 +22,7 @@ "dependencies": { "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "@stacks/profile": "^6.9.0", "cross-fetch": "^3.1.5", "jsontokens": "^4.0.1" diff --git a/packages/auth/src/profile.ts b/packages/auth/src/profile.ts index f4df77aed..5358d9baa 100644 --- a/packages/auth/src/profile.ts +++ b/packages/auth/src/profile.ts @@ -1,5 +1,5 @@ import { resolveZoneFileToProfile } from '@stacks/profile'; -import { StacksMainnet, StacksNetwork, StacksNetworkName } from '@stacks/network'; +import { ApiParam } from '@stacks/transactions'; export interface ProfileLookupOptions { username: string; @@ -16,7 +16,9 @@ export interface ProfileLookupOptions { * blockstack.js [[getNameInfo]] function. * @returns {Promise} that resolves to a profile object */ -export function lookupProfile(lookupOptions: ProfileLookupOptions): Promise> { +export function lookupProfile( + lookupOptions: ProfileLookupOptions & ApiParam +): Promise> { if (!lookupOptions.username) { return Promise.reject(new Error('No username provided')); } diff --git a/packages/auth/tsconfig.build.json b/packages/auth/tsconfig.build.json index 167339c35..9641f70be 100644 --- a/packages/auth/tsconfig.build.json +++ b/packages/auth/tsconfig.build.json @@ -14,9 +14,6 @@ { "path": "../encryption/tsconfig.build.json" }, - { - "path": "../network/tsconfig.build.json" - }, { "path": "../profile/tsconfig.build.json" } diff --git a/packages/bns/package.json b/packages/bns/package.json index aaa37bd0f..7e6558f1a 100755 --- a/packages/bns/package.json +++ b/packages/bns/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "@stacks/transactions": "^6.9.0" }, "devDependencies": { diff --git a/packages/bns/tsconfig.build.json b/packages/bns/tsconfig.build.json index e6c63a0c7..ac95f0816 100644 --- a/packages/bns/tsconfig.build.json +++ b/packages/bns/tsconfig.build.json @@ -11,9 +11,6 @@ { "path": "../common/tsconfig.build.json" }, - { - "path": "../network/tsconfig.build.json" - }, { "path": "../transactions/tsconfig.build.json" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 7d4375a97..6d6f3a31d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "@stacks/bns": "^6.9.0", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "@stacks/stacking": "^6.9.0", "@stacks/storage": "^6.9.0", "@stacks/transactions": "^6.9.0", diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index 5845d0f58..c83e621c1 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -14,9 +14,6 @@ { "path": "../common/tsconfig.build.json" }, - { - "path": "../network/tsconfig.build.json" - }, { "path": "../stacking/tsconfig.build.json" }, diff --git a/packages/common/package.json b/packages/common/package.json index f6f3ae0a5..d4d4ff1f3 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -21,7 +21,8 @@ }, "dependencies": { "@types/bn.js": "^5.1.0", - "@types/node": "^18.0.4" + "@types/node": "^18.0.4", + "cross-fetch": "^3.1.5" }, "devDependencies": { "bn.js": "^5.2.1", diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index 8f89794e9..92265fef9 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -2,6 +2,7 @@ /** * @ignore + * todo: remove this file (needed for CLI?) */ const config = { // network: network.defaults.MAINNET_DEFAULT, diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 58f920990..809b7b2d2 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -1,41 +1,9 @@ -/** - * The **chain** ID. - * Is used for signing, so transactions can't be replayed on other chains. - */ -export enum ChainID { - Testnet = 0x80000000, - Mainnet = 0x00000001, -} - -/** - * The **transaction** version. - * Is used for signing, so transactions can't be replayed on other networks. - */ -export enum TransactionVersion { - Mainnet = 0x00, - Testnet = 0x80, -} - -/** - * The **peer** network ID. - * Typically not used in signing, but used for broadcasting to the P2P network. - * It can also be used to determine the parent of a subnet. - * - * **Attention:** - * For mainnet/testnet the v2/info response `.network_id` refers to the chain ID. - * For subnets the v2/info response `.network_id` refers to the peer network ID and the chain ID (they are the same for subnets). - * The `.parent_network_id` refers to the actual peer network ID (of the parent) in both cases. - */ -export enum PeerNetworkID { - Mainnet = 0x17000000, - Testnet = 0xff000000, -} +export const HIRO_MAINNET_URL = 'https://stacks-node-api.mainnet.stacks.co'; +export const HIRO_TESTNET_URL = 'https://stacks-node-api.testnet.stacks.co'; +export const DEVNET_URL = 'http://localhost:3999'; /** @ignore internal */ export const PRIVATE_KEY_COMPRESSED_LENGTH = 33; /** @ignore internal */ export const PRIVATE_KEY_UNCOMPRESSED_LENGTH = 32; - -/** @ignore internal */ -export const BLOCKSTACK_DEFAULT_GAIA_HUB_URL = 'https://hub.blockstack.org'; diff --git a/packages/network/src/fetch.ts b/packages/common/src/fetch.ts similarity index 93% rename from packages/network/src/fetch.ts rename to packages/common/src/fetch.ts index 1b82d98f1..da4c25227 100644 --- a/packages/network/src/fetch.ts +++ b/packages/common/src/fetch.ts @@ -1,4 +1,4 @@ -import 'cross-fetch/polyfill'; +// todo: move fetch (only types and helpers below) to @stacks/common ? (has nothing to do with transactions theorectically) // Define a default request options and allow modification using getters, setters // Reference: https://developer.mozilla.org/en-US/docs/Web/API/Request/Request @@ -50,6 +50,15 @@ export async function fetchWrapper(input: RequestInfo, init?: RequestInit): Prom export type FetchFn = (url: string, init?: RequestInit) => Promise; +/** @ignore Internally used for letting networking functions specify "API" options */ +export type ApiParam = { + /** Optional API object (for `.url` and `.fetch`) used for API/Node, defaults to use mainnet */ + api?: { + url: string; + fetch: FetchFn; + }; +}; + export interface RequestContext { fetch: FetchFn; url: string; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d4e027599..b87e479f1 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -6,3 +6,5 @@ export * from './constants'; export * from './signatures'; export * from './keys'; export * from './buffer'; +export * from './types'; +export * from './fetch'; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts new file mode 100644 index 000000000..d234d69fa --- /dev/null +++ b/packages/common/src/types.ts @@ -0,0 +1,2 @@ +/** Hex-encoded string (without a 0x prefix) */ +export type Hex = string; // todo: should prefix always be allowed? diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 6b5caf15d..e1a71c87c 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -390,7 +390,18 @@ export function intToBigInt(value: IntegerType, signed: boolean): bigint { * Adds a `0x` prefix to a string if it does not already have one. */ export function with0x(value: string): string { - return value.startsWith('0x') ? value : `0x${value}`; + return /^0x/i.test(value) // startsWith('0x') case insensitive + ? value + : `0x${value}`; +} + +/** + * Removes the `0x` prefix of a string if it has one. + */ +export function without0x(value: string): string { + return /^0x/i.test(value) // startsWith('0x') case insensitive + ? value.slice(2) + : value; } /** @@ -621,3 +632,12 @@ export function concatArray(elements: (Uint8Array | number[] | number)[]) { export function isInstance(object: any, type: any) { return object instanceof type || object?.constructor?.name?.toLowerCase() === type.name; } + +/** + * Checks whether a string is a valid hex string, and has a length of 64 characters. + */ +export function validateHash256(hex: string): boolean { + hex = without0x(hex); + if (hex.length !== 64) return false; + return /^[0-9a-fA-F]+$/.test(hex); +} diff --git a/packages/common/tests/errors.test.ts b/packages/common/tests/errors.test.ts index 853779f34..3a42a1c7e 100644 --- a/packages/common/tests/errors.test.ts +++ b/packages/common/tests/errors.test.ts @@ -1,9 +1,8 @@ -import { InvalidDIDError } from '../src/errors' +import { InvalidDIDError } from '../src/errors'; test('InvalidDIDError', () => { - const error = new InvalidDIDError('the message') - expect(error.message.indexOf('the message')).toEqual(0) - expect(error.parameter).toEqual(undefined) - expect((error).param).toEqual(undefined) -}) - + const error = new InvalidDIDError('the message'); + expect(error.message.indexOf('the message')).toEqual(0); + expect(error.parameter).toEqual(undefined); + expect((error).param).toEqual(undefined); +}); diff --git a/packages/common/tests/utils.test.ts b/packages/common/tests/utils.test.ts index a9a7fab6a..0b1de20f0 100644 --- a/packages/common/tests/utils.test.ts +++ b/packages/common/tests/utils.test.ts @@ -8,6 +8,7 @@ import { toTwos, bigIntToBytes, intToBigInt, + validateHash256, } from '../src'; import BN from 'bn.js'; @@ -92,7 +93,7 @@ test('fromTwos', () => { expect( Number( fromTwos( - BigInt('0xffffffffffffffffffffffffffffffff' + 'fffffffffffffffffffffffffffffffe'), + BigInt('0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe'), BigInt(256) ) ) @@ -100,29 +101,29 @@ test('fromTwos', () => { expect( Number( fromTwos( - BigInt('0xffffffffffffffffffffffffffffffff' + 'ffffffffffffffffffffffffffffffff'), + BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), BigInt(256) ) ) ).toBe(-1); expect( fromTwos( - BigInt('0x7fffffffffffffffffffffffffffffff' + 'ffffffffffffffffffffffffffffffff'), + BigInt('0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), BigInt(256) ).toString(10) ).toEqual( BigInt( - '5789604461865809771178549250434395392663499' + '2332820282019728792003956564819967' + '57896044618658097711785492504343953926634992332820282019728792003956564819967' ).toString(10) ); expect( fromTwos( - BigInt('0x80000000000000000000000000000000' + '00000000000000000000000000000000'), + BigInt('0x8000000000000000000000000000000000000000000000000000000000000000'), BigInt(256) ).toString(10) ).toEqual( BigInt( - '-578960446186580977117854925043439539266349' + '92332820282019728792003956564819968' + '-57896044618658097711785492504343953926634992332820282019728792003956564819968' ).toString(10) ); }); @@ -145,13 +146,13 @@ test('toTwos', () => { ); expect( toTwos( - BigInt('5789604461865809771178549250434395392663' + '4992332820282019728792003956564819967'), + BigInt('57896044618658097711785492504343953926634992332820282019728792003956564819967'), BigInt(256) ).toString(16) ).toEqual('7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); expect( toTwos( - BigInt('-578960446186580977117854925043439539266' + '34992332820282019728792003956564819968'), + BigInt('-57896044618658097711785492504343953926634992332820282019728792003956564819968'), BigInt(256) ).toString(16) ).toEqual('8000000000000000000000000000000000000000000000000000000000000000'); @@ -166,3 +167,23 @@ test('Should accept bn.js instance', () => { expect(nativeBigInt.toString()).toEqual(value); }); + +describe(validateHash256, () => { + const TXID = '117a6522b4e9ec27ff10bbe3940a4a07fd58e5352010b4143992edb05a7130c7'; + + test.each([ + { txid: TXID, expected: true }, // without 0x + { txid: `0x${TXID}`, expected: true }, // with 0x + { txid: TXID.split('30c7')[0], expected: false }, // too short + { + txid: 'Failed to deserialize posted transaction: Invalid Stacks string: non-printable or non-ASCII string', + expected: false, // string without txid + }, + { + txid: `Failed to deserialize posted transaction: Invalid Stacks string: non-printable or non-ASCII string. ${TXID}`, + expected: false, // string with txid + }, + ])('txid is validated as hash 256', ({ txid, expected }) => { + expect(validateHash256(txid)).toEqual(expected); + }); +}); diff --git a/packages/encryption/src/keys.ts b/packages/encryption/src/keys.ts index 56d88eb9b..b15ec08c4 100644 --- a/packages/encryption/src/keys.ts +++ b/packages/encryption/src/keys.ts @@ -2,11 +2,11 @@ import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { getPublicKey as nobleGetPublicKey, signSync, utils } from '@noble/secp256k1'; import { + PRIVATE_KEY_COMPRESSED_LENGTH, bytesToHex, concatBytes, hexToBytes, privateKeyToBytes, - PRIVATE_KEY_COMPRESSED_LENGTH, readUInt8, } from '@stacks/common'; import base58 from 'bs58'; diff --git a/packages/network/CHANGELOG.md b/packages/network/CHANGELOG.md deleted file mode 100644 index b5788e768..000000000 --- a/packages/network/CHANGELOG.md +++ /dev/null @@ -1,268 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## [6.8.1](https://github.com/hirosystems/stacks.js/compare/v6.8.0...v6.8.1) (2023-09-18) - - -### Bug Fixes - -* add hiro product header to default network fetch function ([58eb3af](https://github.com/hirosystems/stacks.js/commit/58eb3af347c05c32065358ea66bfb372d7658e59)) - - - -## [6.8.0](https://github.com/hirosystems/stacks.js/compare/v6.7.0...v6.8.0) (2023-09-04) - -**Note:** Version bump only for package @stacks/network - - - - - -## [6.5.5](https://github.com/hirosystems/stacks.js/compare/v6.5.4...v6.5.5) (2023-07-14) - -**Note:** Version bump only for package @stacks/network - - - - - -## [6.5.4](https://github.com/hirosystems/stacks.js/compare/v6.5.3...v6.5.4) (2023-05-17) - -**Note:** Version bump only for package @stacks/network - - - - - -## [6.5.2](https://github.com/hirosystems/stacks.js/compare/v6.5.1...v6.5.2) (2023-04-28) - -**Note:** Version bump only for package @stacks/network - - - - - -## [6.5.1](https://github.com/hirosystems/stacks.js/compare/v6.5.0...v6.5.1) (2023-04-19) - - -### Bug Fixes - -* add StacksDevnet constructor, closes [#1470](https://github.com/hirosystems/stacks.js/issues/1470) ([5789937](https://github.com/hirosystems/stacks.js/commit/5789937655f351bc07c26086f851653e20ab9c8c)) - - - -## [6.3.0](https://github.com/hirosystems/stacks.js/compare/v6.2.1...v6.3.0) (2023-03-17) - - -### Features - -* implement `getContractMapEntry` function ([#1461](https://github.com/hirosystems/stacks.js/issues/1461)) ([7031ead](https://github.com/hirosystems/stacks.js/commit/7031ead112f7333d165f5946eae0481f6aa9a20f)) - - - -## [6.1.1](https://github.com/hirosystems/stacks.js/compare/v6.1.0...v6.1.1) (2023-01-30) - -**Note:** Version bump only for package @stacks/network - - - - - -## [6.1.0](https://github.com/hirosystems/stacks.js/compare/v6.0.2...v6.1.0) (2023-01-06) - -**Note:** Version bump only for package @stacks/network - - - - - -## [6.0.0](https://github.com/hirosystems/stacks.js/compare/v5.0.3...v6.0.0) (2022-11-23) - -**Note:** Version bump only for package @stacks/network - - - - - -## [5.0.3](https://github.com/hirosystems/stacks.js/compare/v5.0.2...v5.0.3) (2022-11-18) - -**Note:** Version bump only for package @stacks/network - - - - - -## [5.0.0](https://github.com/hirosystems/stacks.js/compare/v4.3.8...v5.0.0) (2022-09-30) - - -### ⚠ BREAKING CHANGES - -* Removes compatibility with `bip32` package from @stacks/wallet-sdk. Now all derivation methods only rely on HDKey from @scure/bip32. -* To reduce the bundle sizes of applications using Stacks.js we are moving away from Buffer (a polyfill to match Node.js APIs) to Uint8Arrays (which Buffers use in the background anyway). To make the switch easier we have introduced a variety of methods for converting between strings and Uint8Arrays: `hexToBytes`, `bytesToHex`, `utf8ToBytes`, `bytesToUtf8`, `asciiToBytes`, `bytesToAscii`, and `concatBytes`. - - -### Features - -* switch from buffer to uint8array ([#1343](https://github.com/hirosystems/stacks.js/issues/1343)) ([5445b73](https://github.com/hirosystems/stacks.js/commit/5445b73e05ec0c09414395331bfd37788545f1e1)) - - - -## [4.3.5](https://github.com/hirosystems/stacks.js/compare/v4.3.4...v4.3.5) (2022-08-23) - -**Note:** Version bump only for package @stacks/network - - - - - -## [4.3.4](https://github.com/hirosystems/stacks.js/compare/v4.3.3...v4.3.4) (2022-08-02) - -**Note:** Version bump only for package @stacks/network - - - - - -## [4.3.2](https://github.com/hirosystems/stacks.js/compare/v4.3.1...v4.3.2) (2022-07-11) - -**Note:** Version bump only for package @stacks/network - - - - - -# [4.3.0](https://github.com/hirosystems/stacks.js/compare/v4.2.2...v4.3.0) (2022-06-16) - -**Note:** Version bump only for package @stacks/network - - - - - -# [4.2.0](https://github.com/hirosystems/stacks.js/compare/v4.1.2...v4.2.0) (2022-05-25) - -**Note:** Version bump only for package @stacks/network - - - - - -# [4.1.0](https://github.com/blockstack/blockstack.js/compare/v4.0.2...v4.1.0) (2022-05-19) - - -### Features - -* add fetch middleware for api keys and request init ([ef45632](https://github.com/blockstack/blockstack.js/commit/ef456327a3e1dcdc2aa364cbe55e47225029c5d2)) - - - - - -## [4.0.2](https://github.com/blockstack/blockstack.js/compare/v4.0.2-beta.1...v4.0.2) (2022-05-19) - -**Note:** Version bump only for package @stacks/network - - - - - -## [4.0.1](https://github.com/blockstack/blockstack.js/compare/v4.0.1-beta.1...v4.0.1) (2022-05-09) - -**Note:** Version bump only for package @stacks/network - - - - - -# [4.0.0](https://github.com/blockstack/blockstack.js/compare/v4.0.0-beta.2...v4.0.0) (2022-04-20) - -**Note:** Version bump only for package @stacks/network - - - - - -# [3.5.0](https://github.com/blockstack/blockstack.js/compare/v3.5.0-beta.3...v3.5.0) (2022-03-30) - -**Note:** Version bump only for package @stacks/network - - - - - -# [3.3.0](https://github.com/blockstack/blockstack.js/compare/v3.2.1-beta.0...v3.3.0) (2022-02-23) - -**Note:** Version bump only for package @stacks/network - - - - - -## [3.2.1-beta.0](https://github.com/blockstack/blockstack.js/compare/v3.2.0...v3.2.1-beta.0) (2022-02-23) - -**Note:** Version bump only for package @stacks/network - - - - - -# [3.2.0](https://github.com/blockstack/blockstack.js/compare/v3.1.1...v3.2.0) (2022-02-02) - - -### Features - -* reduce reliance on network package ([422fda3](https://github.com/blockstack/blockstack.js/commit/422fda3cd43e16ae24ea9d97297b423a90823672)) - - - - - -## [2.0.1](https://github.com/blockstack/blockstack.js/compare/v2.0.1-beta.2...v2.0.1) (2021-08-09) - -**Note:** Version bump only for package @stacks/network - - - - - -## [2.0.1-beta.2](https://github.com/blockstack/blockstack.js/compare/v2.0.1-beta.1...v2.0.1-beta.2) (2021-08-06) - -**Note:** Version bump only for package @stacks/network - - - - - -## [2.0.1-beta.1](https://github.com/blockstack/blockstack.js/compare/v2.0.0-beta.1...v2.0.1-beta.1) (2021-07-26) - - -### Bug Fixes - -* BREAKING CHANGE: make coreApiUrl readonly for stacks network and initialize in constructor ([5d8cf6d](https://github.com/blockstack/blockstack.js/commit/5d8cf6d366665dace2df8102049d3f7ac1bf437e)) -* broken types following diverged network config changes ([1cd9612](https://github.com/blockstack/blockstack.js/commit/1cd96128334465c461665cf079532f66b893b938)) -* removeread only and add private field to prevent run time assignment ([62709aa](https://github.com/blockstack/blockstack.js/commit/62709aa5b6483299718063482bc26d6e94cc8c1c)) - - -### Features - -* add regtest to list of available networks ([f572477](https://github.com/blockstack/blockstack.js/commit/f572477ca0e5bc5e862c8a4e2fcc276655ee55a3)), closes [#1041](https://github.com/blockstack/blockstack.js/issues/1041) - - - - - -# [2.0.0-beta.2](https://github.com/blockstack/blockstack.js/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2021-07-26) - - -### Bug Fixes - -* BREAKING CHANGE: make coreApiUrl readonly for stacks network and initialize in constructor ([5d8cf6d](https://github.com/blockstack/blockstack.js/commit/5d8cf6d366665dace2df8102049d3f7ac1bf437e)) -* broken types following diverged network config changes ([1cd9612](https://github.com/blockstack/blockstack.js/commit/1cd96128334465c461665cf079532f66b893b938)) -* removeread only and add private field to prevent run time assignment ([62709aa](https://github.com/blockstack/blockstack.js/commit/62709aa5b6483299718063482bc26d6e94cc8c1c)) - - -### Features - -* add regtest to list of available networks ([f572477](https://github.com/blockstack/blockstack.js/commit/f572477ca0e5bc5e862c8a4e2fcc276655ee55a3)), closes [#1041](https://github.com/blockstack/blockstack.js/issues/1041) diff --git a/packages/network/README.md b/packages/network/README.md deleted file mode 100644 index a3e43ae40..000000000 --- a/packages/network/README.md +++ /dev/null @@ -1,128 +0,0 @@ -# @stacks/network - -Network and API library for working with Stacks blockchain nodes. - -## Installation - -``` -npm install @stacks/network -``` - -## Usage - -### Create a Stacks mainnet, testnet or mocknet network - -```typescript -import { StacksMainnet, StacksTestnet, StacksMocknet } from '@stacks/network'; - -const network = new StacksMainnet(); - -const testnet = new StacksTestnet(); - -const mocknet = new StacksMocknet(); -``` - -### Set a custom node URL - -```typescript -const network = new StacksMainnet({ url: 'https://www.mystacksnode.com/' }); -``` - -### Check if network is mainnet - -```typescript -const isMainnet = network.isMainnet(); -``` - -### Network usage in transaction building - -```typescript -import { makeSTXTokenTransfer } from '@stacks/transactions'; - -const txOptions = { - network, - recipient: 'SP2BS6HD7TN34V8Z5BNF8Q2AW3K8K2DPV4264CF26', - amount: new BigNum(12345), - senderKey: 'b244296d5907de9864c0b0d51f98a13c52890be0404e83f273144cd5b9960eed01', -}; - -const transaction = await makeSTXTokenTransfer(txOptions); -``` - -### Use the built-in API key middleware - -Some Stacks APIs make use API keys to provide less rate-limited plans. - -```typescript -import { createApiKeyMiddleware, createFetchFn, StacksMainnet } from '@stacks/network'; -import { broadcastTransaction, getNonce, makeSTXTokenTransfer } from '@stacks/transactions'; - -const myApiMiddleware = createApiKeyMiddleware('example_e8e044a3_41d8b0fe_3dd3988ef302'); -const myFetchFn = createFetchFn(myApiMiddleware); // middlewares can be used to create a new fetch function -const myMainnet = new StacksMainnet({ fetchFn: myFetchFn }); // the fetchFn options can be passed to a StacksNetwork to override the default fetch function - -const txOptions = { - recipient: 'SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159', - amount: 12345n, - senderKey: 'b244296d5907de9864c0b0d51f98a13c52890be0404e83f273144cd5b9960eed01', - memo: 'some memo', - anchorMode: AnchorMode.Any, - network: myMainnet, // make sure to pass in the custom network object -}; -const transaction = await makeSTXTokenTransfer(txOptions); // fee-estimation will use the custom fetchFn - -const response = await broadcastTransaction(transaction, myMainnet); // make sure to broadcast via the custom network object - -// stacks.js functions, which take a StacksNetwork object will use the custom fetchFn -const nonce = await getNonce('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159', myMainnet); -``` - -### Use custom middleware - -Middleware can be used to hook into network calls before sending a request or after receiving a response. - -```typescript -import { createFetchFn, RequestContext, ResponseContext, StacksTestnet } from '@stacks/network'; -import { broadcastTransaction, getNonce, makeSTXTokenTransfer } from '@stacks/transactions'; - -const preMiddleware = (ctx: RequestContext) => { - ctx.init.headers = new Headers(); - ctx.init.headers.set('x-foo', 'bar'); // override headers and set new `x-foo` header -}; -const postMiddleware = (ctx: ResponseContext) => { - console.log(await ctx.response.json()); // log response body as json -}; - -const fetchFn = createFetchFn({ pre: preMiddleware, post: preMiddleware }); // a middleware can contain `pre`, `post`, or both -const network = new StacksTestnet({ fetchFn }); - -// stacks.js functions, which take a StacksNetwork object will use the custom fetchFn -const nonce = await getNonce('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159', network); -``` - -### Get various API URLs - -```typescript -const txBroadcastUrl = network.getBroadcastApiUrl(); - -const feeEstimateUrl = network.getTransferFeeEstimateApiUrl(); - -const address = 'SP2BS6HD7TN34V8Z5BNF8Q2AW3K8K2DPV4264CF26'; -const accountInfoUrl = network.getAccountApiUrl(address); - -const contractName = 'hello_world'; -const abiUrl = network.getAbiApiUrl(address, contractName); - -const functionName = 'hello'; -const readOnlyFunctionCallUrl = network.getReadOnlyFunctionCallApiUrl( - address, - contractName, - functionName -); - -const nodeInfoUrl = network.getInfoUrl(); - -const blockTimeUrl = network.getBlockTimeInfoUrl(); - -const poxInfoUrl = network.getPoxInfoUrl(); -``` diff --git a/packages/network/src/index.ts b/packages/network/src/index.ts deleted file mode 100644 index a2f2f921a..000000000 --- a/packages/network/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './fetch'; -export * from './network'; diff --git a/packages/network/src/network.ts b/packages/network/src/network.ts deleted file mode 100644 index 82bbdffbc..000000000 --- a/packages/network/src/network.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { TransactionVersion, ChainID } from '@stacks/common'; -import { createFetchFn, FetchFn } from './fetch'; - -export const HIRO_MAINNET_DEFAULT = 'https://stacks-node-api.mainnet.stacks.co'; -export const HIRO_TESTNET_DEFAULT = 'https://stacks-node-api.testnet.stacks.co'; -export const HIRO_MOCKNET_DEFAULT = 'http://localhost:3999'; - -/** - * Used for constructing Network instances - * @related {@link StacksNetwork}, {@link StacksMainnet}, {@link StacksTestnet}, {@link StacksDevnet}, {@link StacksMocknet} - */ -export interface NetworkConfig { - /** The base API/node URL for the network fetch calls */ - url: string; - /** An optional custom fetch function to override default behaviors */ - fetchFn?: FetchFn; -} - -/** @ignore internal */ -export const StacksNetworks = ['mainnet', 'testnet', 'devnet', 'mocknet'] as const; -/** The enum-style names of different common Stacks networks */ -export type StacksNetworkName = (typeof StacksNetworks)[number]; - -/** - * The base class for Stacks networks. Typically used via its subclasses. - * @related {@link StacksMainnet}, {@link StacksTestnet}, {@link StacksDevnet}, {@link StacksMocknet} - */ -export class StacksNetwork { - version: TransactionVersion = TransactionVersion.Mainnet; - chainId: ChainID = ChainID.Mainnet; - bnsLookupUrl = 'https://stacks-node-api.mainnet.stacks.co'; - broadcastEndpoint = '/v2/transactions'; - transferFeeEstimateEndpoint = '/v2/fees/transfer'; - transactionFeeEstimateEndpoint = '/v2/fees/transaction'; - accountEndpoint = '/v2/accounts'; - contractAbiEndpoint = '/v2/contracts/interface'; - readOnlyFunctionCallEndpoint = '/v2/contracts/call-read'; - - readonly coreApiUrl: string; - - fetchFn: FetchFn; - - constructor(networkConfig: NetworkConfig) { - this.coreApiUrl = networkConfig.url; - this.fetchFn = networkConfig.fetchFn ?? createFetchFn(); - } - - /** A static network constructor from a network name */ - static fromName = (networkName: StacksNetworkName): StacksNetwork => { - switch (networkName) { - case 'mainnet': - return new StacksMainnet(); - case 'testnet': - return new StacksTestnet(); - case 'devnet': - return new StacksDevnet(); - case 'mocknet': - return new StacksMocknet(); - default: - throw new Error( - `Invalid network name provided. Must be one of the following: ${StacksNetworks.join( - ', ' - )}` - ); - } - }; - - /** @ignore internal */ - static fromNameOrNetwork = (network: StacksNetworkName | StacksNetwork) => { - if (typeof network !== 'string' && 'version' in network) { - return network; - } - - return StacksNetwork.fromName(network); - }; - - /** Returns `true` if the network is configured to 'mainnet', based on the TransactionVersion */ - isMainnet = () => this.version === TransactionVersion.Mainnet; - getBroadcastApiUrl = () => `${this.coreApiUrl}${this.broadcastEndpoint}`; - getTransferFeeEstimateApiUrl = () => `${this.coreApiUrl}${this.transferFeeEstimateEndpoint}`; - getTransactionFeeEstimateApiUrl = () => - `${this.coreApiUrl}${this.transactionFeeEstimateEndpoint}`; - getAccountApiUrl = (address: string) => - `${this.coreApiUrl}${this.accountEndpoint}/${address}?proof=0`; - getAccountExtendedBalancesApiUrl = (address: string) => - `${this.coreApiUrl}/extended/v1/address/${address}/balances`; - getAbiApiUrl = (address: string, contract: string) => - `${this.coreApiUrl}${this.contractAbiEndpoint}/${address}/${contract}`; - getReadOnlyFunctionCallApiUrl = ( - contractAddress: string, - contractName: string, - functionName: string - ) => - `${this.coreApiUrl}${ - this.readOnlyFunctionCallEndpoint - }/${contractAddress}/${contractName}/${encodeURIComponent(functionName)}`; - getInfoUrl = () => `${this.coreApiUrl}/v2/info`; - getBlockTimeInfoUrl = () => `${this.coreApiUrl}/extended/v1/info/network_block_times`; - getPoxInfoUrl = () => `${this.coreApiUrl}/v2/pox`; - getRewardsUrl = (address: string, options?: any) => { - let url = `${this.coreApiUrl}/extended/v1/burnchain/rewards/${address}`; - if (options) { - url = `${url}?limit=${options.limit}&offset=${options.offset}`; - } - return url; - }; - getRewardsTotalUrl = (address: string) => - `${this.coreApiUrl}/extended/v1/burnchain/rewards/${address}/total`; - getRewardHoldersUrl = (address: string, options?: any) => { - let url = `${this.coreApiUrl}/extended/v1/burnchain/reward_slot_holders/${address}`; - if (options) { - url = `${url}?limit=${options.limit}&offset=${options.offset}`; - } - return url; - }; - getStackerInfoUrl = (contractAddress: string, contractName: string) => - `${this.coreApiUrl}${this.readOnlyFunctionCallEndpoint} - ${contractAddress}/${contractName}/get-stacker-info`; - getDataVarUrl = (contractAddress: string, contractName: string, dataVarName: string) => - `${this.coreApiUrl}/v2/data_var/${contractAddress}/${contractName}/${dataVarName}?proof=0`; - getMapEntryUrl = (contractAddress: string, contractName: string, mapName: string) => - `${this.coreApiUrl}/v2/map_entry/${contractAddress}/${contractName}/${mapName}?proof=0`; - getNameInfo(fullyQualifiedName: string) { - /* - TODO: Update to v2 API URL for name lookups - */ - const nameLookupURL = `${this.bnsLookupUrl}/v1/names/${fullyQualifiedName}`; - return this.fetchFn(nameLookupURL) - .then(resp => { - if (resp.status === 404) { - throw new Error('Name not found'); - } else if (resp.status !== 200) { - throw new Error(`Bad response status: ${resp.status}`); - } else { - return resp.json(); - } - }) - .then(nameInfo => { - // the returned address _should_ be in the correct network --- - // stacks node gets into trouble because it tries to coerce back to mainnet - // and the regtest transaction generation libraries want to use testnet addresses - if (nameInfo.address) { - return Object.assign({}, nameInfo, { address: nameInfo.address }); - } else { - return nameInfo; - } - }); - } -} - -/** - * A {@link StacksNetwork} with the parameters for the Stacks mainnet. - * Pass a `url` option to override the default Hiro hosted Stacks node API. - * Pass a `fetchFn` option to customize the default networking functions. - * @example - * ``` - * const network = new StacksMainnet(); - * const network = new StacksMainnet({ url: "https://stacks-node-api.mainnet.stacks.co" }); - * const network = new StacksMainnet({ fetch: createFetchFn() }); - * ``` - * @related {@link createFetchFn}, {@link createApiKeyMiddleware} - */ -export class StacksMainnet extends StacksNetwork { - version = TransactionVersion.Mainnet; - chainId = ChainID.Mainnet; - - constructor(opts?: Partial) { - super({ - url: opts?.url ?? HIRO_MAINNET_DEFAULT, - fetchFn: opts?.fetchFn, - }); - } -} - -/** - * A {@link StacksNetwork} with the parameters for the Stacks testnet. - * Pass a `url` option to override the default Hiro hosted Stacks node API. - * Pass a `fetchFn` option to customize the default networking functions. - * @example - * ``` - * const network = new StacksTestnet(); - * const network = new StacksTestnet({ url: "https://stacks-node-api.testnet.stacks.co" }); - * const network = new StacksTestnet({ fetch: createFetchFn() }); - * ``` - * @related {@link createFetchFn}, {@link createApiKeyMiddleware} - */ -export class StacksTestnet extends StacksNetwork { - version = TransactionVersion.Testnet; - chainId = ChainID.Testnet; - - constructor(opts?: Partial) { - super({ - url: opts?.url ?? HIRO_TESTNET_DEFAULT, - fetchFn: opts?.fetchFn, - }); - } -} - -/** - * A {@link StacksNetwork} using the testnet parameters, but `localhost:3999` as the API URL. - */ -export class StacksMocknet extends StacksNetwork { - version = TransactionVersion.Testnet; - chainId = ChainID.Testnet; - - constructor(opts?: Partial) { - super({ - url: opts?.url ?? HIRO_MOCKNET_DEFAULT, - fetchFn: opts?.fetchFn, - }); - } -} - -/** Alias for {@link StacksMocknet} */ -export const StacksDevnet = StacksMocknet; diff --git a/packages/network/tests/network.test.ts b/packages/network/tests/network.test.ts deleted file mode 100644 index efe35a444..000000000 --- a/packages/network/tests/network.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - HIRO_MAINNET_DEFAULT, - HIRO_MOCKNET_DEFAULT, - HIRO_TESTNET_DEFAULT, - StacksMainnet, - StacksMocknet, - StacksNetwork, - StacksTestnet, -} from '../src/network'; - -describe('Setting coreApiUrl', () => { - it('sets mainnet default url', () => { - const mainnet = new StacksMainnet(); - expect(mainnet.coreApiUrl).toEqual(HIRO_MAINNET_DEFAULT); - }); - it('sets testnet url', () => { - const testnet = new StacksTestnet(); - expect(testnet.coreApiUrl).toEqual(HIRO_TESTNET_DEFAULT); - }); - it('sets mocknet url', () => { - const mocknet = new StacksMocknet(); - expect(mocknet.coreApiUrl).toEqual(HIRO_MOCKNET_DEFAULT); - }); - it('sets custom url', () => { - const customURL = 'https://customurl.com'; - const customNET = new StacksMainnet({ url: customURL }); - expect(customNET.coreApiUrl).toEqual(customURL); - }); -}); - -it('uses the correct constructor for stacks network from name strings', () => { - expect(StacksNetwork.fromName('mainnet').constructor.toString()).toContain('StacksMainnet'); - expect(StacksNetwork.fromName('testnet').constructor.toString()).toContain('StacksTestnet'); - expect(StacksNetwork.fromName('devnet').constructor.toString()).toContain('StacksMocknet'); // devnet is an alias for mocknet - expect(StacksNetwork.fromName('mocknet').constructor.toString()).toContain('StacksMocknet'); -}); diff --git a/packages/profile/package.json b/packages/profile/package.json index 08974d6b1..c4a69d514 100644 --- a/packages/profile/package.json +++ b/packages/profile/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "@stacks/transactions": "^6.9.0", "jsontokens": "^4.0.1", "schema-inspector": "^2.0.2", diff --git a/packages/profile/tsconfig.build.json b/packages/profile/tsconfig.build.json index e6c63a0c7..ac95f0816 100644 --- a/packages/profile/tsconfig.build.json +++ b/packages/profile/tsconfig.build.json @@ -11,9 +11,6 @@ { "path": "../common/tsconfig.build.json" }, - { - "path": "../network/tsconfig.build.json" - }, { "path": "../transactions/tsconfig.build.json" } diff --git a/packages/stacking/package.json b/packages/stacking/package.json index ea091511e..207a822a7 100644 --- a/packages/stacking/package.json +++ b/packages/stacking/package.json @@ -23,7 +23,7 @@ "@scure/base": "1.1.1", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "@stacks/stacks-blockchain-api-types": "^0.61.0", "@stacks/transactions": "^6.9.0", "bs58": "^5.0.0" diff --git a/packages/stacking/src/index.ts b/packages/stacking/src/index.ts index 6ffb25db6..403a0ef2a 100644 --- a/packages/stacking/src/index.ts +++ b/packages/stacking/src/index.ts @@ -1,6 +1,5 @@ // @ts-ignore import { IntegerType, intToBigInt } from '@stacks/common'; -import { StacksNetwork } from '@stacks/network'; import { BurnchainRewardListResponse, BurnchainRewardSlotHolderListResponse, @@ -8,14 +7,17 @@ import { } from '@stacks/stacks-blockchain-api-types'; import { AnchorMode, + ApiParam, BufferCV, ClarityType, ClarityValue, ContractCallOptions, ContractCallPayload, + FetchFn, OptionalCV, PrincipalCV, ResponseErrorCV, + StacksNetwork, StacksTransaction, TupleCV, TxBroadcastResult, @@ -152,13 +154,11 @@ export type DelegationInfo = details: { amount_micro_stx: bigint; delegated_to: string; - pox_address: - | { - version: Uint8Array; - hashbytes: Uint8Array; - } - | undefined; - until_burn_ht: number | undefined; + pox_address?: { + version: number; + hashbytes: Uint8Array; + }; + until_burn_ht?: number; }; }; @@ -337,7 +337,8 @@ export interface StackAggregationIncreaseOptions { export class StackingClient { constructor( public address: string, - public network: StacksNetwork + public network: StacksNetwork, + public api: { url: string; fetch: FetchFn } ) {} /** @@ -347,7 +348,7 @@ export class StackingClient { */ async getCoreInfo(): Promise { const url = this.network.getInfoUrl(); - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -357,7 +358,7 @@ export class StackingClient { */ async getPoxInfo(): Promise { const url = this.network.getPoxInfoUrl(); - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -367,7 +368,7 @@ export class StackingClient { */ async getTargetBlockTime(): Promise { const url = this.network.getBlockTimeInfoUrl(); - const res = await this.network.fetchFn(url).then(res => res.json()); + const res = await this.api.fetch(url).then(res => res.json()); if (this.network.isMainnet()) { return res.mainnet.target_block_time; @@ -378,7 +379,7 @@ export class StackingClient { async getAccountStatus(): Promise { const url = this.network.getAccountApiUrl(this.address); - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -397,7 +398,7 @@ export class StackingClient { */ async getAccountExtendedBalances(): Promise { const url = this.network.getAccountExtendedBalancesApiUrl(this.address); - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -429,7 +430,7 @@ export class StackingClient { */ async getRewardsTotalForBtcAddress(): Promise { const url = this.network.getRewardsTotalUrl(this.address); - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -440,7 +441,7 @@ export class StackingClient { options?: PaginationOptions ): Promise { const url = `${this.network.getRewardsUrl(this.address, options)}`; - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -451,7 +452,7 @@ export class StackingClient { options?: PaginationOptions ): Promise { const url = `${this.network.getRewardHoldersUrl(this.address, options)}`; - return this.network.fetchFn(url).then(res => res.json()); + return this.api.fetch(url).then(res => res.json()); } /** @@ -560,7 +561,7 @@ export class StackingClient { const [address, name] = pox2.contract_id.split('.'); const pox2ConfiguredUrl = this.network.getDataVarUrl(address, name, 'configured'); const isPox2NotYetConfigured = - (await this.network.fetchFn(pox2ConfiguredUrl).then(r => r.text())) !== '{"data":"0x03"}'; // PoX-2 is configured on fork if data is 0x03 + (await this.api.fetch(pox2ConfiguredUrl).then(r => r.text())) !== '{"data":"0x03"}'; // PoX-2 is configured on fork if data is 0x03 // => Period 1 if (isPox2NotYetConfigured) { @@ -1344,7 +1345,7 @@ export class StackingClient { const delegatedTo = tupleCV.data['delegated-to'] as PrincipalCV; const poxAddress = unwrapMap(tupleCV.data['pox-addr'] as OptionalCV, tuple => ({ - version: (tuple.data['version'] as BufferCV).buffer, + version: (tuple.data['version'] as BufferCV).buffer[0], hashbytes: (tuple.data['hashbytes'] as BufferCV).buffer, })); const untilBurnBlockHeight = unwrap(tupleCV.data['until-burn-ht'] as OptionalCV); diff --git a/packages/stacking/tests/stacking.test.ts b/packages/stacking/tests/stacking.test.ts index d230508e0..688f86b95 100644 --- a/packages/stacking/tests/stacking.test.ts +++ b/packages/stacking/tests/stacking.test.ts @@ -1,10 +1,10 @@ import { bigIntToBytes, bytesToHex, hexToBytes } from '@stacks/common'; import { base58CheckDecode } from '@stacks/encryption'; -import { StacksMainnet, StacksTestnet } from '@stacks/network'; import { AnchorMode, ClarityType, ReadOnlyFunctionOptions, + STACKS_TESTNET, SignedContractCallOptions, TupleCV, bufferCV, @@ -188,7 +188,6 @@ test('check stacking eligibility true', async () => { test('check stacking eligibility false bad cycles', async () => { const address = 'ST3XKKN4RPV69NN1PHFDNX3TYKXT7XPC4N8KC1ARH'; const poxAddress = '1Xik14zRm29UsyS6DjhYg4iZeZqsDa8D3'; - const network = new StacksTestnet(); const expectedErrorString = StackingErrors[StackingErrors.ERR_STACKING_INVALID_LOCK_PERIOD]; const functionCallResponse = responseErrorCV(intCV(2)); @@ -200,7 +199,7 @@ test('check stacking eligibility false bad cycles', async () => { })); // eslint-disable-next-line @typescript-eslint/no-var-requires const { StackingClient } = require('../src'); // needed for jest.mock module - const client = new StackingClient(address, network); + const client = new StackingClient(address, STACKS_TESTNET); fetchMock.mockResponse(request => { const url = request.url; diff --git a/packages/stacking/tsconfig.build.json b/packages/stacking/tsconfig.build.json index 8d57d415d..b35ccf20b 100644 --- a/packages/stacking/tsconfig.build.json +++ b/packages/stacking/tsconfig.build.json @@ -14,14 +14,9 @@ { "path": "../encryption/tsconfig.build.json" }, - { - "path": "../network/tsconfig.build.json" - }, { "path": "../transactions/tsconfig.build.json" } ], - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/packages/storage/package.json b/packages/storage/package.json index a3fccc853..b485373fc 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -23,7 +23,7 @@ "@stacks/auth": "^6.9.0", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "base64-js": "^1.5.1", "jsontokens": "^4.0.1" }, diff --git a/packages/transactions/package.json b/packages/transactions/package.json index d30eabe30..aa0540d38 100644 --- a/packages/transactions/package.json +++ b/packages/transactions/package.json @@ -28,8 +28,9 @@ "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "c32check": "^2.0.0", + "cross-fetch": "^3.1.5", "lodash.clonedeep": "^4.5.0" }, "devDependencies": { diff --git a/packages/transactions/src/api.ts b/packages/transactions/src/api.ts new file mode 100644 index 000000000..fb56befef --- /dev/null +++ b/packages/transactions/src/api.ts @@ -0,0 +1,130 @@ +import { + DEVNET_URL, + FetchFn, + HIRO_MAINNET_URL, + HIRO_TESTNET_URL, + Hex, + createFetchFn, +} from '@stacks/common'; +import { TransactionVersion } from './constants'; +import { ClarityAbi } from './contract-abi'; +import { broadcastTransaction, estimateTransaction, getAbi, getNonce } from './fetch'; +import { STACKS_MAINNET, StacksNetwork, StacksNetworkName, networkFrom } from './network'; +import { StacksTransaction } from './transaction'; +import { FeeEstimation, TxBroadcastResult } from './types'; + +export class StacksNodeApi { + // TODO + bnsLookupUrl = 'https://stacks-node-api.mainnet.stacks.co'; + + public url: string; + public fetch: FetchFn; + + public network: StacksNetwork; + + constructor({ + url, + fetch, + network = STACKS_MAINNET, + }: { + /** The base API/node URL for the network fetch calls */ + url?: string; + /** Stacks network object (defaults to {@link STACKS_MAINNET}) */ + network?: StacksNetworkName | StacksNetwork; + /** An optional custom fetch function to override default behaviors */ + fetch?: FetchFn; + } = {}) { + this.url = url ?? deriveDefaultUrl(network); + this.fetch = fetch ?? createFetchFn(); + this.network = networkFrom(network); + } + + /** Returns `true` if the network is configured to 'mainnet', based on the TransactionVersion */ + isMainnet = () => this.network.transactionVersion === TransactionVersion.Mainnet; + + /** + * Broadcast a serialized transaction to a Stacks node (which will validate and forward to the network). + * @param transaction - The transaction to broadcast + * @param attachment - Optional attachment to include with the transaction + * @returns a Promise that resolves to a {@link TxBroadcastResult} object + */ + broadcastTransaction = async ( + transaction: StacksTransaction, + attachment?: Uint8Array | string + ): Promise => { + // todo: should we use a opts object instead of positional args here? + return broadcastTransaction({ transaction, attachment, api: this }); + }; + + /** + * Lookup the nonce for an address from a core node + * @param address - The Stacks address to look up the next nonce for + * @return A promise that resolves to a bigint of the next nonce + */ + getNonce = async (address: string): Promise => { + return getNonce({ address, api: this }); + }; + + /** + * Estimate the total transaction fee in microstacks for a Stacks transaction + * @param payload - The transaction to estimate fees for + * @param estimatedLength - Optional argument that provides the endpoint with an + * estimation of the final length (in bytes) of the transaction, including any post-conditions + * and signatures + * @return A promise that resolves to an array of {@link FeeEstimate} + */ + estimateTransaction = async ( + payload: Hex, + estimatedLength?: number + ): Promise<[FeeEstimation, FeeEstimation, FeeEstimation]> => { + return estimateTransaction({ payload, estimatedLength, api: this }); + }; + + /** + * Fetch a contract's ABI + * @param contractAddress - The contracts address + * @param contractName - The contracts name + * @returns A promise that resolves to a ClarityAbi if the operation succeeds + */ + getAbi = async (contractAddress: string, contractName: string): Promise => { + return getAbi({ contractAddress, contractName, api: this }); + }; + + // todo: migrate to new api pattern + getNameInfo(fullyQualifiedName: string) { + /* + TODO: Update to v2 API URL for name lookups + */ + const nameLookupURL = `${this.bnsLookupUrl}/v1/names/${fullyQualifiedName}`; + return this.fetch(nameLookupURL) + .then((resp: any) => { + if (resp.status === 404) { + throw new Error('Name not found'); + } else if (resp.status !== 200) { + throw new Error(`Bad response status: ${resp.status}`); + } else { + return resp.json(); + } + }) + .then((nameInfo: any) => { + // the returned address _should_ be in the correct network --- + // stacks node gets into trouble because it tries to coerce back to mainnet + // and the regtest transaction generation libraries want to use testnet addresses + if (nameInfo.address) { + return Object.assign({}, nameInfo, { address: nameInfo.address }); + } else { + return nameInfo; + } + }); + } +} + +export function deriveDefaultUrl(network: StacksNetwork | StacksNetworkName) { + network = networkFrom(network); + + return !network || network.transactionVersion === TransactionVersion.Mainnet + ? HIRO_MAINNET_URL // default to mainnet if no network is given or txVersion is mainnet + : network.magicBytes === 'id' + ? DEVNET_URL // default to devnet if magicBytes are devnet + : HIRO_TESTNET_URL; +} diff --git a/packages/transactions/src/authorization.ts b/packages/transactions/src/authorization.ts index 69f6976ba..3fb85db82 100644 --- a/packages/transactions/src/authorization.ts +++ b/packages/transactions/src/authorization.ts @@ -86,6 +86,40 @@ export type SpendingCondition = SingleSigSpendingCondition | MultiSigSpendingCon export type SpendingConditionOpts = SingleSigSpendingConditionOpts | MultiSigSpendingConditionOpts; +export function createSpendingCondition( + options: + | { + // Single-sig + publicKey: string; + nonce: IntegerType; + fee: IntegerType; + } + | { + // Multi-sig + publicKeys: string[]; + numSignatures: number; + nonce: IntegerType; + fee: IntegerType; + } +) { + if ('publicKey' in options) { + return createSingleSigSpendingCondition( + AddressHashMode.SerializeP2PKH, + options.publicKey, + options.nonce, + options.fee + ); + } + // multi-sig + return createMultiSigSpendingCondition( + AddressHashMode.SerializeP2SH, + options.numSignatures, + options.publicKeys, + options.nonce, + options.fee + ); +} + export function createSingleSigSpendingCondition( hashMode: SingleSigHashMode, pubKey: string, diff --git a/packages/transactions/src/builders.ts b/packages/transactions/src/builders.ts index 3b6b72101..c3cdd50c2 100644 --- a/packages/transactions/src/builders.ts +++ b/packages/transactions/src/builders.ts @@ -1,41 +1,32 @@ -import { bytesToHex, hexToBytes, IntegerType, intToBigInt } from '@stacks/common'; -import { - StacksNetwork, - StacksMainnet, - StacksNetworkName, - StacksTestnet, - FetchFn, - createFetchFn, -} from '@stacks/network'; +import { bytesToHex, hexToBytes, IntegerType } from '@stacks/common'; import { c32address } from 'c32check'; +import { StacksNodeApi } from './api'; import { - Authorization, - createMultiSigSpendingCondition, createSingleSigSpendingCondition, + createSpendingCondition, createSponsoredAuth, createStandardAuth, - SpendingCondition, MultiSigSpendingCondition, } from './authorization'; -import { ClarityValue, deserializeCV, NoneCV, PrincipalCV, serializeCV } from './clarity'; +import { ClarityValue, PrincipalCV } from './clarity'; import { AddressHashMode, AddressVersion, AnchorMode, + AnchorModeName, + ClarityVersion, FungibleConditionCode, NonFungibleConditionCode, PayloadType, PostConditionMode, - SingleSigHashMode, - TransactionVersion, - TxRejectedReason, RECOVERABLE_ECDSA_SIG_LENGTH_BYTES, + SingleSigHashMode, StacksMessageType, - ClarityVersion, - AnchorModeName, + TransactionVersion, + whenTransactionVersion, } from './constants'; import { ClarityAbi, validateContractCall } from './contract-abi'; -import { NoEstimateAvailableError } from './errors'; +import { estimateFee, getAbi, getNonce } from './fetch'; import { createStacksPrivateKey, getPublicKey, @@ -44,12 +35,17 @@ import { publicKeyToAddress, publicKeyToString, } from './keys'; +import { + networkFrom, + STACKS_MAINNET, + STACKS_TESTNET, + StacksNetwork, + StacksNetworkName, +} from './network'; import { createContractCallPayload, createSmartContractPayload, createTokenTransferPayload, - Payload, - serializePayload, } from './payload'; import { createFungiblePostCondition, @@ -68,443 +64,7 @@ import { import { TransactionSigner } from './signer'; import { StacksTransaction } from './transaction'; import { createLPList } from './types'; -import { cvToHex, omit, parseReadOnlyResponse, validateTxId } from './utils'; - -/** - * Lookup the nonce for an address from a core node - * - * @param {string} address - the c32check address to look up - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to look up address on - * - * @return a promise that resolves to an integer - */ -export async function getNonce( - address: string, - network?: StacksNetworkName | StacksNetwork -): Promise { - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? new StacksMainnet()); - const url = derivedNetwork.getAccountApiUrl(address); - - const response = await derivedNetwork.fetchFn(url); - if (!response.ok) { - let msg = ''; - try { - msg = await response.text(); - } catch (error) {} - throw new Error( - `Error fetching nonce. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - const responseText = await response.text(); - const result = JSON.parse(responseText) as { nonce: string }; - return BigInt(result.nonce); -} - -/** - * @deprecated Use the new {@link estimateTransaction} function instead. - * - * Estimate the total transaction fee in microstacks for a token transfer - * - * @param {StacksTransaction} transaction - the token transfer transaction to estimate fees for - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to estimate transaction for - * - * @return a promise that resolves to number of microstacks per byte - */ -export async function estimateTransfer( - transaction: StacksTransaction, - network?: StacksNetworkName | StacksNetwork -): Promise { - if (transaction.payload.payloadType !== PayloadType.TokenTransfer) { - throw new Error( - `Transaction fee estimation only possible with ${ - PayloadType[PayloadType.TokenTransfer] - } transactions. Invoked with: ${PayloadType[transaction.payload.payloadType]}` - ); - } - - return estimateTransferUnsafe(transaction, network); -} - -/** - * @deprecated Use the new {@link estimateTransaction} function instead. - * @internal - */ -export async function estimateTransferUnsafe( - transaction: StacksTransaction, - network?: StacksNetworkName | StacksNetwork -): Promise { - const requestHeaders = { - Accept: 'application/text', - }; - - const fetchOptions = { - method: 'GET', - headers: requestHeaders, - }; - - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? deriveNetwork(transaction)); - const url = derivedNetwork.getTransferFeeEstimateApiUrl(); - - const response = await derivedNetwork.fetchFn(url, fetchOptions); - if (!response.ok) { - let msg = ''; - try { - msg = await response.text(); - } catch (error) {} - throw new Error( - `Error estimating transaction fee. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - const feeRateResult = await response.text(); - const txBytes = BigInt(transaction.serialize().byteLength); - const feeRate = BigInt(feeRateResult); - return feeRate * txBytes; -} - -interface FeeEstimation { - fee: number; - fee_rate: number; -} -interface FeeEstimateResponse { - cost_scalar_change_by_byte: bigint; - estimated_cost: { - read_count: bigint; - read_length: bigint; - runtime: bigint; - write_count: bigint; - write_length: bigint; - }; - estimated_cost_scalar: bigint; - estimations: [FeeEstimation, FeeEstimation, FeeEstimation]; -} - -/** - * Estimate the total transaction fee in microstacks for a Stacks transaction - * - * @param {StacksTransaction} transaction - the transaction to estimate fees for - * @param {number} estimatedLen - is an optional argument that provides the endpoint with an - * estimation of the final length (in bytes) of the transaction, including any post-conditions - * and signatures - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to estimate transaction fees for - * - * @return a promise that resolves to FeeEstimate - */ -export async function estimateTransaction( - transactionPayload: Payload, - estimatedLen?: number, - network?: StacksNetworkName | StacksNetwork -): Promise<[FeeEstimation, FeeEstimation, FeeEstimation]> { - const options = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - transaction_payload: bytesToHex(serializePayload(transactionPayload)), - ...(estimatedLen ? { estimated_len: estimatedLen } : {}), - }), - }; - - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? new StacksMainnet()); - const url = derivedNetwork.getTransactionFeeEstimateApiUrl(); - - const response = await derivedNetwork.fetchFn(url, options); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - - if (body?.reason === 'NoEstimateAvailable') { - throw new NoEstimateAvailableError(body?.reason_data?.message ?? ''); - } - - throw new Error( - `Error estimating transaction fee. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${body}"` - ); - } - - const data: FeeEstimateResponse = await response.json(); - return data.estimations; -} - -export type SerializationRejection = { - error: string; - reason: TxRejectedReason.Serialization; - reason_data: { - message: string; - }; - txid: string; -}; - -export type DeserializationRejection = { - error: string; - reason: TxRejectedReason.Deserialization; - reason_data: { - message: string; - }; - txid: string; -}; - -export type SignatureValidationRejection = { - error: string; - reason: TxRejectedReason.SignatureValidation; - reason_data: { - message: string; - }; - txid: string; -}; - -export type BadNonceRejection = { - error: string; - reason: TxRejectedReason.BadNonce; - reason_data: { - expected: number; - actual: number; - is_origin: boolean; - principal: boolean; - }; - txid: string; -}; - -export type FeeTooLowRejection = { - error: string; - reason: TxRejectedReason.FeeTooLow; - reason_data: { - expected: number; - actual: number; - }; - txid: string; -}; - -export type NotEnoughFundsRejection = { - error: string; - reason: TxRejectedReason.NotEnoughFunds; - reason_data: { - expected: string; - actual: string; - }; - txid: string; -}; - -export type NoSuchContractRejection = { - error: string; - reason: TxRejectedReason.NoSuchContract; - reason_data?: undefined; - txid: string; -}; - -export type NoSuchPublicFunctionRejection = { - error: string; - reason: TxRejectedReason.NoSuchPublicFunction; - reason_data?: undefined; - txid: string; -}; - -export type BadFunctionArgumentRejection = { - error: string; - reason: TxRejectedReason.BadFunctionArgument; - reason_data: { - message: string; - }; - txid: string; -}; - -export type ContractAlreadyExistsRejection = { - error: string; - reason: TxRejectedReason.ContractAlreadyExists; - reason_data: { - contract_identifier: string; - }; - txid: string; -}; - -export type PoisonMicroblocksDoNotConflictRejection = { - error: string; - reason: TxRejectedReason.PoisonMicroblocksDoNotConflict; - reason_data?: undefined; - txid: string; -}; - -export type PoisonMicroblockHasUnknownPubKeyHashRejection = { - error: string; - reason: TxRejectedReason.PoisonMicroblockHasUnknownPubKeyHash; - reason_data?: undefined; - txid: string; -}; - -export type PoisonMicroblockIsInvalidRejection = { - error: string; - reason: TxRejectedReason.PoisonMicroblockIsInvalid; - reason_data?: undefined; - txid: string; -}; - -export type BadAddressVersionByteRejection = { - error: string; - reason: TxRejectedReason.BadAddressVersionByte; - reason_data?: undefined; - txid: string; -}; - -export type NoCoinbaseViaMempoolRejection = { - error: string; - reason: TxRejectedReason.NoCoinbaseViaMempool; - reason_data?: undefined; - txid: string; -}; - -export type ServerFailureNoSuchChainTipRejection = { - error: string; - reason: TxRejectedReason.ServerFailureNoSuchChainTip; - reason_data?: undefined; - txid: string; -}; - -export type ServerFailureDatabaseRejection = { - error: string; - reason: TxRejectedReason.ServerFailureDatabase; - reason_data: { - message: string; - }; - txid: string; -}; - -export type ServerFailureOtherRejection = { - error: string; - reason: TxRejectedReason.ServerFailureOther; - reason_data: { - message: string; - }; - txid: string; -}; - -export type TxBroadcastResultOk = { - txid: string; - error?: undefined; - reason?: undefined; - reason_data?: undefined; -}; - -export type TxBroadcastResultRejected = - | SerializationRejection - | DeserializationRejection - | SignatureValidationRejection - | BadNonceRejection - | FeeTooLowRejection - | NotEnoughFundsRejection - | NoSuchContractRejection - | NoSuchPublicFunctionRejection - | BadFunctionArgumentRejection - | ContractAlreadyExistsRejection - | PoisonMicroblocksDoNotConflictRejection - | PoisonMicroblockHasUnknownPubKeyHashRejection - | PoisonMicroblockIsInvalidRejection - | BadAddressVersionByteRejection - | NoCoinbaseViaMempoolRejection - | ServerFailureNoSuchChainTipRejection - | ServerFailureDatabaseRejection - | ServerFailureOtherRejection; - -export type TxBroadcastResult = TxBroadcastResultOk | TxBroadcastResultRejected; - -/** - * Broadcast the signed transaction to a core node - * - * @param {StacksTransaction} transaction - the token transfer transaction to broadcast - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to broadcast transaction to - * - * @returns {Promise} that resolves to a response if the operation succeeds - */ -export async function broadcastTransaction( - transaction: StacksTransaction, - network?: StacksNetworkName | StacksNetwork, - attachment?: Uint8Array -): Promise { - const rawTx = transaction.serialize(); - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? deriveNetwork(transaction)); - const url = derivedNetwork.getBroadcastApiUrl(); - - return broadcastRawTransaction(rawTx, url, attachment, derivedNetwork.fetchFn); -} - -/** - * Broadcast the signed transaction to a core node - * - * @param {Uint8Array} rawTx - the raw serialized transaction bytes to broadcast - * @param {string} url - the broadcast endpoint URL - * - * @returns {Promise} that resolves to a response if the operation succeeds - */ -export async function broadcastRawTransaction( - rawTx: Uint8Array, - url: string, - attachment?: Uint8Array, - fetchFn: FetchFn = createFetchFn() -): Promise { - const options = { - method: 'POST', - headers: { 'Content-Type': attachment ? 'application/json' : 'application/octet-stream' }, - body: attachment - ? JSON.stringify({ - tx: bytesToHex(rawTx), - attachment: bytesToHex(attachment), - }) - : rawTx, - }; - - const response = await fetchFn(url, options); - if (!response.ok) { - try { - return (await response.json()) as TxBroadcastResult; - } catch (e) { - throw Error(`Failed to broadcast transaction: ${(e as Error).message}`); - } - } - - const text = await response.text(); - // Replace extra quotes around txid string - const txid = text.replace(/["]+/g, ''); - if (!validateTxId(txid)) throw new Error(text); - return { txid } as TxBroadcastResult; -} - -/** - * Fetch a contract's ABI - * - * @param {string} address - the contracts address - * @param {string} contractName - the contracts name - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to broadcast transaction to - * - * @returns {Promise} that resolves to a ClarityAbi if the operation succeeds - */ -export async function getAbi( - address: string, - contractName: string, - network: StacksNetworkName | StacksNetwork -): Promise { - const options = { - method: 'GET', - }; - - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network); - const url = derivedNetwork.getAbiApiUrl(address, contractName); - - const response = await derivedNetwork.fetchFn(url, options); - if (!response.ok) { - const msg = await response.text().catch(() => ''); - throw new Error( - `Error fetching contract ABI for contract "${contractName}" at address ${address}. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - - return JSON.parse(await response.text()) as ClarityAbi; -} - -function deriveNetwork(transaction: StacksTransaction) { - switch (transaction.version) { - case TransactionVersion.Mainnet: - return new StacksMainnet(); - case TransactionVersion.Testnet: - return new StacksTestnet(); - } -} +import { omit } from './utils'; export interface MultiSigOptions { numSignatures: number; @@ -514,6 +74,8 @@ export interface MultiSigOptions { /** * STX token transfer transaction options + * + * Note: Standard STX transfer does not allow post-conditions. */ export interface TokenTransferOptions { /** the address of the recipient of the token transfer */ @@ -526,6 +88,8 @@ export interface TokenTransferOptions { nonce?: IntegerType; /** the network that the transaction will ultimately be broadcast to */ network?: StacksNetworkName | StacksNetwork; + /** the node/API used for estimating fee & nonce (using the `api.fetchFn` */ + api?: StacksNodeApi; /** the transaction anchorMode, which specifies whether it should be * included in an anchor block or a microblock */ anchorMode: AnchorModeName | AnchorMode; @@ -569,7 +133,8 @@ export async function makeUnsignedSTXTokenTransfer( const defaultOptions = { fee: BigInt(0), nonce: BigInt(0), - network: new StacksMainnet(), + network: STACKS_MAINNET, + api: new StacksNodeApi({ network: txOptions.network }), memo: '', sponsored: false, }; @@ -578,38 +143,14 @@ export async function makeUnsignedSTXTokenTransfer( const payload = createTokenTransferPayload(options.recipient, options.amount, options.memo); - let authorization: Authorization | null = null; - let spendingCondition: SpendingCondition | null = null; - - if ('publicKey' in options) { - // single-sig - spendingCondition = createSingleSigSpendingCondition( - AddressHashMode.SerializeP2PKH, - options.publicKey, - options.nonce, - options.fee - ); - } else { - // multi-sig - spendingCondition = createMultiSigSpendingCondition( - AddressHashMode.SerializeP2SH, - options.numSignatures, - options.publicKeys, - options.nonce, - options.fee - ); - } - - if (options.sponsored) { - authorization = createSponsoredAuth(spendingCondition); - } else { - authorization = createStandardAuth(spendingCondition); - } - - const network = StacksNetwork.fromNameOrNetwork(options.network); + const network = networkFrom(options.network); + const spendingCondition = createSpendingCondition(options); + const authorization = options.sponsored + ? createSponsoredAuth(spendingCondition) + : createStandardAuth(spendingCondition); const transaction = new StacksTransaction( - network.version, + network.transactionVersion, authorization, payload, undefined, // no post conditions on STX transfers (see SIP-005) @@ -618,18 +159,18 @@ export async function makeUnsignedSTXTokenTransfer( network.chainId ); - if (txOptions.fee === undefined || txOptions.fee === null) { - const fee = await estimateTransactionFeeWithFallback(transaction, network); + if (txOptions.fee == null) { + const fee = await estimateFee({ transaction, api: options.api }); transaction.setFee(fee); } - if (txOptions.nonce === undefined || txOptions.nonce === null) { + if (txOptions.nonce == null) { const addressVersion = - options.network.version === TransactionVersion.Mainnet + options.network.transactionVersion === TransactionVersion.Mainnet ? AddressVersion.MainnetSingleSig : AddressVersion.TestnetSingleSig; - const senderAddress = c32address(addressVersion, transaction.auth.spendingCondition!.signer); - const txNonce = await getNonce(senderAddress, options.network); + const address = c32address(addressVersion, transaction.auth.spendingCondition!.signer); + const txNonce = await getNonce({ address, api: options.api }); transaction.setNonce(txNonce); } @@ -694,6 +235,8 @@ export interface BaseContractDeployOptions { nonce?: IntegerType; /** the network that the transaction will ultimately be broadcast to */ network?: StacksNetworkName | StacksNetwork; + /** the node/API used for estimating fee & nonce (using the `api.fetchFn` */ + api?: StacksNodeApi; /** the transaction anchorMode, which specifies whether it should be * included in an anchor block or a microblock */ anchorMode: AnchorModeName | AnchorMode; @@ -729,58 +272,6 @@ export interface SignedMultiSigContractDeployOptions extends BaseContractDeployO signerKeys: string[]; } -/** - * @deprecated Use the new {@link estimateTransaction} function insterad. - * - * Estimate the total transaction fee in microstacks for a contract deploy - * - * @param {StacksTransaction} transaction - the token transfer transaction to estimate fees for - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to estimate transaction for - * - * @return a promise that resolves to number of microstacks per byte - */ -export async function estimateContractDeploy( - transaction: StacksTransaction, - network?: StacksNetworkName | StacksNetwork -): Promise { - if ( - transaction.payload.payloadType !== PayloadType.SmartContract && - transaction.payload.payloadType !== PayloadType.VersionedSmartContract - ) { - throw new Error( - `Contract deploy fee estimation only possible with ${ - PayloadType[PayloadType.SmartContract] - } transactions. Invoked with: ${PayloadType[transaction.payload.payloadType]}` - ); - } - - const requestHeaders = { - Accept: 'application/text', - }; - - const fetchOptions = { - method: 'GET', - headers: requestHeaders, - }; - - // Place holder estimate until contract deploy fee estimation is fully implemented on Stacks - // blockchain core - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? deriveNetwork(transaction)); - const url = derivedNetwork.getTransferFeeEstimateApiUrl(); - - const response = await derivedNetwork.fetchFn(url, fetchOptions); - if (!response.ok) { - const msg = await response.text().catch(() => ''); - throw new Error( - `Error estimating contract deploy fee. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - const feeRateResult = await response.text(); - const txBytes = intToBigInt(transaction.serialize().byteLength, false); - const feeRate = intToBigInt(feeRateResult, false); - return feeRate * txBytes; -} - /** * Generates a Clarity smart contract deploy transaction * @@ -831,7 +322,8 @@ export async function makeUnsignedContractDeploy( const defaultOptions = { fee: BigInt(0), nonce: BigInt(0), - network: new StacksMainnet(), + network: STACKS_MAINNET, + api: new StacksNodeApi({ network: txOptions.network }), postConditionMode: PostConditionMode.Deny, sponsored: false, clarityVersion: ClarityVersion.Clarity2, @@ -845,36 +337,11 @@ export async function makeUnsignedContractDeploy( options.clarityVersion ); - let authorization: Authorization | null = null; - - let spendingCondition: SpendingCondition | null = null; - - if ('publicKey' in options) { - // single-sig - spendingCondition = createSingleSigSpendingCondition( - AddressHashMode.SerializeP2PKH, - options.publicKey, - options.nonce, - options.fee - ); - } else { - // multi-sig - spendingCondition = createMultiSigSpendingCondition( - AddressHashMode.SerializeP2SH, - options.numSignatures, - options.publicKeys, - options.nonce, - options.fee - ); - } - - if (options.sponsored) { - authorization = createSponsoredAuth(spendingCondition); - } else { - authorization = createStandardAuth(spendingCondition); - } - - const network = StacksNetwork.fromNameOrNetwork(options.network); + const network = networkFrom(options.network); + const spendingCondition = createSpendingCondition(options); + const authorization = options.sponsored + ? createSponsoredAuth(spendingCondition) + : createStandardAuth(spendingCondition); const postConditions: PostCondition[] = []; if (options.postConditions && options.postConditions.length > 0) { @@ -885,7 +352,7 @@ export async function makeUnsignedContractDeploy( const lpPostConditions = createLPList(postConditions); const transaction = new StacksTransaction( - network.version, + network.transactionVersion, authorization, payload, lpPostConditions, @@ -895,17 +362,17 @@ export async function makeUnsignedContractDeploy( ); if (txOptions.fee === undefined || txOptions.fee === null) { - const fee = await estimateTransactionFeeWithFallback(transaction, network); + const fee = await estimateFee({ transaction, api: options.api }); transaction.setFee(fee); } if (txOptions.nonce === undefined || txOptions.nonce === null) { const addressVersion = - options.network.version === TransactionVersion.Mainnet + options.network.transactionVersion === TransactionVersion.Mainnet ? AddressVersion.MainnetSingleSig : AddressVersion.TestnetSingleSig; - const senderAddress = c32address(addressVersion, transaction.auth.spendingCondition!.signer); - const txNonce = await getNonce(senderAddress, options.network); + const address = c32address(addressVersion, transaction.auth.spendingCondition!.signer); + const txNonce = await getNonce({ address, api: options.api }); transaction.setNonce(txNonce); } @@ -923,11 +390,12 @@ export interface ContractCallOptions { functionArgs: ClarityValue[]; /** transaction fee in microstacks */ fee?: IntegerType; - feeEstimateApiUrl?: string; /** the transaction nonce, which must be increased monotonically with each new transaction */ nonce?: IntegerType; /** the Stacks blockchain network that will ultimately be used to broadcast this transaction */ network?: StacksNetworkName | StacksNetwork; + /** the node/API used for estimating fee & nonce (using the `api.fetchFn` */ + api?: StacksNodeApi; /** the transaction anchorMode, which specifies whether it should be * included in an anchor block or a microblock */ anchorMode: AnchorModeName | AnchorMode; @@ -962,55 +430,6 @@ export interface SignedMultiSigContractCallOptions extends ContractCallOptions { signerKeys: string[]; } -/** - * @deprecated Use the new {@link estimateTransaction} function insterad. - * - * Estimate the total transaction fee in microstacks for a contract function call - * - * @param {StacksTransaction} transaction - the token transfer transaction to estimate fees for - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to estimate transaction for - * - * @return a promise that resolves to number of microstacks per byte - */ -export async function estimateContractFunctionCall( - transaction: StacksTransaction, - network?: StacksNetworkName | StacksNetwork -): Promise { - if (transaction.payload.payloadType !== PayloadType.ContractCall) { - throw new Error( - `Contract call fee estimation only possible with ${ - PayloadType[PayloadType.ContractCall] - } transactions. Invoked with: ${PayloadType[transaction.payload.payloadType]}` - ); - } - - const requestHeaders = { - Accept: 'application/text', - }; - - const fetchOptions = { - method: 'GET', - headers: requestHeaders, - }; - - // Place holder estimate until contract call fee estimation is fully implemented on Stacks - // blockchain core - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? deriveNetwork(transaction)); - const url = derivedNetwork.getTransferFeeEstimateApiUrl(); - - const response = await derivedNetwork.fetchFn(url, fetchOptions); - if (!response.ok) { - const msg = await response.text().catch(() => ''); - throw new Error( - `Error estimating contract call fee. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - const feeRateResult = await response.text(); - const txBytes = intToBigInt(transaction.serialize().byteLength, false); - const feeRate = intToBigInt(feeRateResult, false); - return feeRate * txBytes; -} - /** * Generates an unsigned Clarity smart contract function call transaction * @@ -1024,7 +443,8 @@ export async function makeUnsignedContractCall( const defaultOptions = { fee: BigInt(0), nonce: BigInt(0), - network: new StacksMainnet(), + network: STACKS_MAINNET, + api: new StacksNodeApi({ network: txOptions.network }), postConditionMode: PostConditionMode.Deny, sponsored: false, }; @@ -1042,7 +462,7 @@ export async function makeUnsignedContractCall( let abi: ClarityAbi; if (typeof options.validateWithAbi === 'boolean') { if (options?.network) { - abi = await getAbi(options.contractAddress, options.contractName, options.network); + abi = await getAbi(options); } else { throw new Error('Network option must be provided in order to validate with ABI'); } @@ -1053,35 +473,11 @@ export async function makeUnsignedContractCall( validateContractCall(payload, abi); } - let spendingCondition: SpendingCondition | null = null; - let authorization: Authorization | null = null; - - if ('publicKey' in options) { - // single-sig - spendingCondition = createSingleSigSpendingCondition( - AddressHashMode.SerializeP2PKH, - options.publicKey, - options.nonce, - options.fee - ); - } else { - // multi-sig - spendingCondition = createMultiSigSpendingCondition( - AddressHashMode.SerializeP2SH, - options.numSignatures, - options.publicKeys, - options.nonce, - options.fee - ); - } - - if (options.sponsored) { - authorization = createSponsoredAuth(spendingCondition); - } else { - authorization = createStandardAuth(spendingCondition); - } - - const network = StacksNetwork.fromNameOrNetwork(options.network); + const network = networkFrom(options.network); + const spendingCondition = createSpendingCondition(options); + const authorization = options.sponsored + ? createSponsoredAuth(spendingCondition) + : createStandardAuth(spendingCondition); const postConditions: PostCondition[] = []; if (options.postConditions && options.postConditions.length > 0) { @@ -1092,7 +488,7 @@ export async function makeUnsignedContractCall( const lpPostConditions = createLPList(postConditions); const transaction = new StacksTransaction( - network.version, + network.transactionVersion, authorization, payload, lpPostConditions, @@ -1102,17 +498,17 @@ export async function makeUnsignedContractCall( ); if (txOptions.fee === undefined || txOptions.fee === null) { - const fee = await estimateTransactionFeeWithFallback(transaction, network); + const fee = await estimateFee({ transaction, api: options.api }); transaction.setFee(fee); } if (txOptions.nonce === undefined || txOptions.nonce === null) { const addressVersion = - network.version === TransactionVersion.Mainnet + network.transactionVersion === TransactionVersion.Mainnet ? AddressVersion.MainnetSingleSig : AddressVersion.TestnetSingleSig; - const senderAddress = c32address(addressVersion, transaction.auth.spendingCondition!.signer); - const txNonce = await getNonce(senderAddress, network); + const address = c32address(addressVersion, transaction.auth.spendingCondition!.signer); + const txNonce = await getNonce({ address, api: options.api }); transaction.setNonce(txNonce); } @@ -1307,146 +703,6 @@ export function makeContractNonFungiblePostCondition( ); } -/** - * Read only function options - * - * @param {String} contractAddress - the c32check address of the contract - * @param {String} contractName - the contract name - * @param {String} functionName - name of the function to be called - * @param {[ClarityValue]} functionArgs - an array of Clarity values as arguments to the function call - * @param {StacksNetwork} network - the Stacks blockchain network this transaction is destined for - * @param {String} senderAddress - the c32check address of the sender - */ - -export interface ReadOnlyFunctionOptions { - contractName: string; - contractAddress: string; - functionName: string; - functionArgs: ClarityValue[]; - /** the network that the contract which contains the function is deployed to */ - network?: StacksNetworkName | StacksNetwork; - /** address of the sender */ - senderAddress: string; -} - -/** - * Calls a function as read-only from a contract interface - * It is not necessary that the function is defined as read-only in the contract - * - * @param {ReadOnlyFunctionOptions} readOnlyFunctionOptions - the options object - * - * Returns an object with a status bool (okay) and a result string that is a serialized clarity value in hex format. - * - * @return {ClarityValue} - */ -export async function callReadOnlyFunction( - readOnlyFunctionOptions: ReadOnlyFunctionOptions -): Promise { - const defaultOptions = { - network: new StacksMainnet(), - }; - - const options = Object.assign(defaultOptions, readOnlyFunctionOptions); - - const { contractName, contractAddress, functionName, functionArgs, senderAddress } = options; - - const network = StacksNetwork.fromNameOrNetwork(options.network); - const url = network.getReadOnlyFunctionCallApiUrl(contractAddress, contractName, functionName); - - const args = functionArgs.map(arg => cvToHex(arg)); - - const body = JSON.stringify({ - sender: senderAddress, - arguments: args, - }); - - const response = await network.fetchFn(url, { - method: 'POST', - body, - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const msg = await response.text().catch(() => ''); - throw new Error( - `Error calling read-only function. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - - return response.json().then(responseJson => parseReadOnlyResponse(responseJson)); -} - -export interface GetContractMapEntryOptions { - /** the contracts address */ - contractAddress: string; - /** the contracts name */ - contractName: string; - /** the map name */ - mapName: string; - /** key to lookup in the map */ - mapKey: ClarityValue; - /** the network that has the contract */ - network?: StacksNetworkName | StacksNetwork; -} - -/** - * Fetch data from a contract data map. - * @param getContractMapEntryOptions - the options object - * @returns - * Promise that resolves to a ClarityValue if the operation succeeds. - * Resolves to NoneCV if the map does not contain the given key, if the map does not exist, or if the contract prinicipal does not exist - */ -export async function getContractMapEntry( - getContractMapEntryOptions: GetContractMapEntryOptions -): Promise { - const defaultOptions = { - network: new StacksMainnet(), - }; - const { contractAddress, contractName, mapName, mapKey, network } = Object.assign( - defaultOptions, - getContractMapEntryOptions - ); - - const derivedNetwork = StacksNetwork.fromNameOrNetwork(network); - const url = derivedNetwork.getMapEntryUrl(contractAddress, contractName, mapName); - - const serializedKeyBytes = serializeCV(mapKey); - const serializedKeyHex = '0x' + bytesToHex(serializedKeyBytes); - - const fetchOptions: RequestInit = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(serializedKeyHex), // endpoint expects a JSON string atom (quote wrapped string) - }; - - const response = await derivedNetwork.fetchFn(url, fetchOptions); - if (!response.ok) { - const msg = await response.text().catch(() => ''); - throw new Error( - `Error fetching map entry for map "${mapName}" in contract "${contractName}" at address ${contractAddress}, using map key "${serializedKeyHex}". Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` - ); - } - const responseBody = await response.text(); - const responseJson: { data?: string } = JSON.parse(responseBody); - if (!responseJson.data) { - throw new Error( - `Error fetching map entry for map "${mapName}" in contract "${contractName}" at address ${contractAddress}, using map key "${serializedKeyHex}". Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the response: "${responseBody}"` - ); - } - let deserializedCv: T; - try { - deserializedCv = deserializeCV(responseJson.data); - } catch (error) { - throw new Error(`Error deserializing Clarity value "${responseJson.data}": ${error}`); - } - return deserializedCv; -} - /** * Sponsored transaction options */ @@ -1463,6 +719,8 @@ export interface SponsorOptionsOpts { sponsorAddressHashmode?: AddressHashMode; /** the Stacks blockchain network that this transaction will ultimately be broadcast to */ network?: StacksNetworkName | StacksNetwork; + /** the node/API used for estimating fee & nonce (using the `api.fetchFn` */ + api?: StacksNodeApi; } /** @@ -1477,35 +735,31 @@ export interface SponsorOptionsOpts { export async function sponsorTransaction( sponsorOptions: SponsorOptionsOpts ): Promise { + const defaultNetwork = whenTransactionVersion(sponsorOptions.transaction.version)({ + [TransactionVersion.Mainnet]: STACKS_MAINNET, + [TransactionVersion.Testnet]: STACKS_TESTNET, + }); // detect network from transaction version + const defaultOptions = { fee: 0 as IntegerType, sponsorNonce: 0 as IntegerType, sponsorAddressHashmode: AddressHashMode.SerializeP2PKH as SingleSigHashMode, - network: - sponsorOptions.transaction.version === TransactionVersion.Mainnet - ? new StacksMainnet() - : new StacksTestnet(), + network: defaultNetwork, + api: new StacksNodeApi({ network: defaultNetwork }), }; const options = Object.assign(defaultOptions, sponsorOptions); - const network = StacksNetwork.fromNameOrNetwork(options.network); const sponsorPubKey = pubKeyfromPrivKey(options.sponsorPrivateKey); - if (sponsorOptions.fee === undefined || sponsorOptions.fee === null) { - let txFee = 0; + if (sponsorOptions.fee == null) { + let txFee: bigint | number = 0; switch (options.transaction.payload.payloadType) { case PayloadType.TokenTransfer: case PayloadType.SmartContract: case PayloadType.VersionedSmartContract: case PayloadType.ContractCall: - const estimatedLen = estimateTransactionByteLength(options.transaction); - try { - txFee = (await estimateTransaction(options.transaction.payload, estimatedLen, network))[1] - .fee; - } catch (e) { - throw e; - } + txFee = BigInt(await estimateFee({ ...options })); break; default: throw new Error( @@ -1518,14 +772,13 @@ export async function sponsorTransaction( options.fee = txFee; } - if (sponsorOptions.sponsorNonce === undefined || sponsorOptions.sponsorNonce === null) { - const addressVersion = - network.version === TransactionVersion.Mainnet - ? AddressVersion.MainnetSingleSig - : AddressVersion.TestnetSingleSig; - - const senderAddress = publicKeyToAddress(addressVersion, sponsorPubKey); - const sponsorNonce = await getNonce(senderAddress, network); + if (sponsorOptions.sponsorNonce == null) { + const addressVersion = whenTransactionVersion(options.transaction.version)({ + [TransactionVersion.Mainnet]: AddressVersion.MainnetSingleSig, + [TransactionVersion.Testnet]: AddressVersion.TestnetSingleSig, + }); // detect address version from transaction version + const address = publicKeyToAddress(addressVersion, sponsorPubKey); + const sponsorNonce = await getNonce({ address, api: options.api }); options.sponsorNonce = sponsorNonce; } @@ -1585,23 +838,3 @@ export function estimateTransactionByteLength(transaction: StacksTransaction): n return transaction.serialize().byteLength; } } - -/** - * Estimates the fee using {@link estimateTransfer} as a fallback if - * {@link estimateTransaction} does not get an estimation due to the - * {@link NoEstimateAvailableError} error. - */ -export async function estimateTransactionFeeWithFallback( - transaction: StacksTransaction, - network: StacksNetwork -): Promise { - try { - const estimatedLen = estimateTransactionByteLength(transaction); - return (await estimateTransaction(transaction.payload, estimatedLen, network))[1].fee; - } catch (error) { - if (error instanceof NoEstimateAvailableError) { - return await estimateTransferUnsafe(transaction, network); - } - throw error; - } -} diff --git a/packages/transactions/src/clarity/deserialize.ts b/packages/transactions/src/clarity/deserialize.ts index b952d21ef..3983b6168 100644 --- a/packages/transactions/src/clarity/deserialize.ts +++ b/packages/transactions/src/clarity/deserialize.ts @@ -19,13 +19,11 @@ import { BytesReader as BytesReader } from '../bytesReader'; import { deserializeAddress, deserializeLPString } from '../types'; import { DeserializationError } from '../errors'; import { stringAsciiCV, stringUtf8CV } from './types/stringCV'; -import { bytesToAscii, bytesToUtf8, hexToBytes } from '@stacks/common'; +import { bytesToAscii, bytesToUtf8, hexToBytes, without0x } from '@stacks/common'; /** * Deserializes clarity value to clarity type - * * @param {value} Uint8Array | string value to be converted to clarity type - ** * @returns {ClarityType} returns the clarity type instance * * @example @@ -46,19 +44,15 @@ import { bytesToAscii, bytesToUtf8, hexToBytes } from '@stacks/common'; export default function deserializeCV( serializedClarityValue: BytesReader | Uint8Array | string ): T { - let bytesReader: BytesReader; - if (typeof serializedClarityValue === 'string') { - const hasHexPrefix = serializedClarityValue.slice(0, 2).toLowerCase() === '0x'; - bytesReader = new BytesReader( - hexToBytes(hasHexPrefix ? serializedClarityValue.slice(2) : serializedClarityValue) - ); - } else if (serializedClarityValue instanceof Uint8Array) { - bytesReader = new BytesReader(serializedClarityValue); - } else { - bytesReader = serializedClarityValue; - } + const bytesReader: BytesReader = + typeof serializedClarityValue === 'string' + ? new BytesReader(hexToBytes(without0x(serializedClarityValue))) + : serializedClarityValue instanceof Uint8Array + ? new BytesReader(serializedClarityValue) + : serializedClarityValue; + const type = bytesReader.readUInt8Enum(ClarityType, n => { - throw new DeserializationError(`Cannot recognize Clarity Type: ${n}`); + throw new DeserializationError(`Invalid Clarity type: ${n}`); }); switch (type) { @@ -113,7 +107,7 @@ export default function deserializeCV( for (let i = 0; i < tupleLength; i++) { const clarityName = deserializeLPString(bytesReader).content; if (clarityName === undefined) { - throw new DeserializationError('"content" is undefined'); + throw new DeserializationError('Tuple name is undefined'); } tupleContents[clarityName] = deserializeCV(bytesReader); } @@ -131,7 +125,7 @@ export default function deserializeCV( default: throw new DeserializationError( - 'Unable to deserialize Clarity Value from Uint8Array. Could not find valid Clarity Type.' + `Failed to deserialize Clarity value from Uint8Array. Invalid Clarity type: ${type}` ); } } diff --git a/packages/transactions/src/clarity/serialize.ts b/packages/transactions/src/clarity/serialize.ts index dca638a3e..f01151406 100644 --- a/packages/transactions/src/clarity/serialize.ts +++ b/packages/transactions/src/clarity/serialize.ts @@ -1,34 +1,33 @@ import { + asciiToBytes, + bigIntToBytes, concatArray, concatBytes, - bigIntToBytes, toTwos, - writeUInt32BE, utf8ToBytes, - asciiToBytes, + writeUInt32BE, } from '@stacks/common'; -import { serializeAddress, serializeLPString } from '../types'; -import { createLPString } from '../postcondition-types'; import { BooleanCV, - OptionalCV, BufferCV, - IntCV, - UIntCV, - StandardPrincipalCV, + ClarityValue, ContractPrincipalCV, - ResponseCV, + IntCV, ListCV, + OptionalCV, + ResponseCV, + StandardPrincipalCV, TupleCV, - ClarityValue, + UIntCV, } from '.'; -import { ClarityType } from './constants'; - +import { CLARITY_INT_BYTE_SIZE, CLARITY_INT_SIZE } from '../constants'; import { SerializationError } from '../errors'; +import { createLPString } from '../postcondition-types'; +import { serializeAddress, serializeLPString } from '../types'; +import { ClarityType } from './constants'; import { StringAsciiCV, StringUtf8CV } from './types/stringCV'; -import { CLARITY_INT_BYTE_SIZE, CLARITY_INT_SIZE } from '../constants'; -function bytesWithTypeID(typeId: ClarityType, bytes: Uint8Array): Uint8Array { +function bytesWithTypeId(typeId: ClarityType, bytes: Uint8Array): Uint8Array { return concatArray([typeId, bytes]); } @@ -40,39 +39,39 @@ function serializeOptionalCV(cv: OptionalCV): Uint8Array { if (cv.type === ClarityType.OptionalNone) { return new Uint8Array([cv.type]); } else { - return bytesWithTypeID(cv.type, serializeCV(cv.value)); + return bytesWithTypeId(cv.type, serializeCV(cv.value)); } } function serializeBufferCV(cv: BufferCV): Uint8Array { const length = new Uint8Array(4); writeUInt32BE(length, cv.buffer.length, 0); - return bytesWithTypeID(cv.type, concatBytes(length, cv.buffer)); + return bytesWithTypeId(cv.type, concatBytes(length, cv.buffer)); } function serializeIntCV(cv: IntCV): Uint8Array { const bytes = bigIntToBytes(toTwos(cv.value, BigInt(CLARITY_INT_SIZE)), CLARITY_INT_BYTE_SIZE); - return bytesWithTypeID(cv.type, bytes); + return bytesWithTypeId(cv.type, bytes); } function serializeUIntCV(cv: UIntCV): Uint8Array { const bytes = bigIntToBytes(cv.value, CLARITY_INT_BYTE_SIZE); - return bytesWithTypeID(cv.type, bytes); + return bytesWithTypeId(cv.type, bytes); } function serializeStandardPrincipalCV(cv: StandardPrincipalCV): Uint8Array { - return bytesWithTypeID(cv.type, serializeAddress(cv.address)); + return bytesWithTypeId(cv.type, serializeAddress(cv.address)); } function serializeContractPrincipalCV(cv: ContractPrincipalCV): Uint8Array { - return bytesWithTypeID( + return bytesWithTypeId( cv.type, concatBytes(serializeAddress(cv.address), serializeLPString(cv.contractName)) ); } function serializeResponseCV(cv: ResponseCV) { - return bytesWithTypeID(cv.type, serializeCV(cv.value)); + return bytesWithTypeId(cv.type, serializeCV(cv.value)); } function serializeListCV(cv: ListCV) { @@ -87,7 +86,7 @@ function serializeListCV(cv: ListCV) { bytesArray.push(serializedValue); } - return bytesWithTypeID(cv.type, concatArray(bytesArray)); + return bytesWithTypeId(cv.type, concatArray(bytesArray)); } function serializeTupleCV(cv: TupleCV) { @@ -107,7 +106,7 @@ function serializeTupleCV(cv: TupleCV) { bytesArray.push(serializedValue); } - return bytesWithTypeID(cv.type, concatArray(bytesArray)); + return bytesWithTypeId(cv.type, concatArray(bytesArray)); } function serializeStringCV(cv: StringAsciiCV | StringUtf8CV, encoding: 'ascii' | 'utf8') { @@ -120,7 +119,7 @@ function serializeStringCV(cv: StringAsciiCV | StringUtf8CV, encoding: 'ascii' | bytesArray.push(len); bytesArray.push(str); - return bytesWithTypeID(cv.type, concatArray(bytesArray)); + return bytesWithTypeId(cv.type, concatArray(bytesArray)); } function serializeStringAsciiCV(cv: StringAsciiCV) { @@ -133,17 +132,13 @@ function serializeStringUtf8CV(cv: StringUtf8CV) { /** * Serializes clarity value to Uint8Array - * * @param {ClarityValue} value to be converted to bytes - ** * @returns {Uint8Array} returns the bytes - * * @example * ``` * import { intCV, serializeCV } from '@stacks/transactions'; * * const serialized = serializeCV(intCV(100)); // Similarly works for other clarity types as well like listCV, booleanCV ... - * * // * ``` * diff --git a/packages/transactions/src/clarity/types/booleanCV.ts b/packages/transactions/src/clarity/types/booleanCV.ts index 0dfa05e9b..6b43386fa 100644 --- a/packages/transactions/src/clarity/types/booleanCV.ts +++ b/packages/transactions/src/clarity/types/booleanCV.ts @@ -1,20 +1,18 @@ import { ClarityType } from '../constants'; -type BooleanCV = TrueCV | FalseCV; +export type BooleanCV = TrueCV | FalseCV; -interface TrueCV { +export interface TrueCV { type: ClarityType.BoolTrue; } -interface FalseCV { +export interface FalseCV { type: ClarityType.BoolFalse; } /** * Converts true to BooleanCV clarity type - * - * @returns {BooleanCV} returns instance of type BooleanCV - * + * @returns returns instance of type BooleanCV * @example * ``` * import { trueCV } from '@stacks/transactions'; @@ -26,13 +24,11 @@ interface FalseCV { * @see * {@link https://github.com/hirosystems/stacks.js/blob/main/packages/transactions/tests/clarity.test.ts | clarity test cases for more examples} */ -const trueCV = (): BooleanCV => ({ type: ClarityType.BoolTrue }); +export const trueCV = (): BooleanCV => ({ type: ClarityType.BoolTrue }); /** * Converts false to BooleanCV clarity type - * - * @returns {BooleanCV} returns instance of type BooleanCV - * + * @returns returns instance of type BooleanCV * @example * ``` * import { falseCV } from '@stacks/transactions'; @@ -44,13 +40,11 @@ const trueCV = (): BooleanCV => ({ type: ClarityType.BoolTrue }); * @see * {@link https://github.com/hirosystems/stacks.js/blob/main/packages/transactions/tests/clarity.test.ts | clarity test cases for more examples} */ -const falseCV = (): BooleanCV => ({ type: ClarityType.BoolFalse }); +export const falseCV = (): BooleanCV => ({ type: ClarityType.BoolFalse }); /** * Converts a boolean to BooleanCV clarity type - * - * @returns {BooleanCV} returns instance of type BooleanCV - * + * @returns returns instance of type BooleanCV * @example * ``` * import { boolCV } from '@stacks/transactions'; @@ -62,6 +56,4 @@ const falseCV = (): BooleanCV => ({ type: ClarityType.BoolFalse }); * @see * {@link https://github.com/hirosystems/stacks.js/blob/main/packages/transactions/tests/clarity.test.ts | clarity test cases for more examples} */ -const boolCV = (bool: boolean) => (bool ? trueCV() : falseCV()); - -export { BooleanCV, TrueCV, FalseCV, boolCV, trueCV, falseCV }; +export const boolCV = (bool: boolean) => (bool ? trueCV() : falseCV()); diff --git a/packages/transactions/src/clarity/types/bufferCV.ts b/packages/transactions/src/clarity/types/bufferCV.ts index cccdacdf5..7009f0157 100644 --- a/packages/transactions/src/clarity/types/bufferCV.ts +++ b/packages/transactions/src/clarity/types/bufferCV.ts @@ -1,18 +1,15 @@ import { utf8ToBytes } from '@stacks/common'; import { ClarityType } from '../constants'; -interface BufferCV { +export interface BufferCV { readonly type: ClarityType.Buffer; readonly buffer: Uint8Array; } /** * Converts a Uint8Array to a BufferCV clarity type - * - * @param {Uint8Array} buffer value to be converted to clarity type - * - * @returns {BufferCV} returns instance of type BufferCV - * + * @param buffer value to be converted to clarity type + * @returns returns instance of type BufferCV * @example * ``` * import { bufferCV } from '@stacks/transactions'; @@ -27,7 +24,7 @@ interface BufferCV { * @see * {@link https://github.com/hirosystems/stacks.js/blob/main/packages/transactions/tests/clarity.test.ts | clarity test cases for more examples} */ -const bufferCV = (buffer: Uint8Array): BufferCV => { +export const bufferCV = (buffer: Uint8Array): BufferCV => { if (buffer.length > 1_000_000) { throw new Error('Cannot construct clarity buffer that is greater than 1MB'); } @@ -37,11 +34,8 @@ const bufferCV = (buffer: Uint8Array): BufferCV => { /** * Converts a string to BufferCV clarity type - * - * @param {str} string input to be converted to bufferCV clarity type - * - * @returns {BufferCV} returns instance of type BufferCV - * + * @param string input to be converted to bufferCV clarity type + * @returns returns instance of type BufferCV * @example * ``` * import { bufferCVFromString } from '@stacks/transactions'; @@ -56,6 +50,4 @@ const bufferCV = (buffer: Uint8Array): BufferCV => { * @see * {@link https://github.com/hirosystems/stacks.js/blob/main/packages/transactions/tests/clarity.test.ts | clarity test cases for more examples} */ -const bufferCVFromString = (str: string): BufferCV => bufferCV(utf8ToBytes(str)); - -export { BufferCV, bufferCV, bufferCVFromString }; +export const bufferCVFromString = (str: string): BufferCV => bufferCV(utf8ToBytes(str)); diff --git a/packages/transactions/src/common.ts b/packages/transactions/src/common.ts index bde9bf6c0..fff81aef6 100644 --- a/packages/transactions/src/common.ts +++ b/packages/transactions/src/common.ts @@ -9,6 +9,8 @@ import { import { c32address } from 'c32check'; import { hexToBytes } from '@stacks/common'; +// todo: rename file to 'address' + export interface Address { readonly type: StacksMessageType.Address; readonly version: AddressVersion; diff --git a/packages/transactions/src/constants.ts b/packages/transactions/src/constants.ts index 47ea8aab2..7721688f1 100644 --- a/packages/transactions/src/constants.ts +++ b/packages/transactions/src/constants.ts @@ -1,13 +1,31 @@ +/** @ignore internal */ +export const BLOCKSTACK_DEFAULT_GAIA_HUB_URL = 'https://hub.blockstack.org'; + /** * The chain ID (unsigned 32-bit integer), used so transactions can't be replayed on other chains. * Similar to the {@link TransactionVersion}. */ -export enum ChainID { +export enum ChainId { Testnet = 0x80000000, Mainnet = 0x00000001, } -export const DEFAULT_CHAIN_ID = ChainID.Mainnet; +/** + * The **peer** network ID. + * Typically not used in signing, but used for broadcasting to the P2P network. + * It can also be used to determine the parent of a subnet. + * + * **Attention:** + * For mainnet/testnet the v2/info response `.network_id` refers to the chain ID. + * For subnets the v2/info response `.network_id` refers to the peer network ID and the chain ID (they are the same for subnets). + * The `.parent_network_id` refers to the actual peer network ID (of the parent) in both cases. + */ +export enum PeerNetworkId { + Mainnet = 0x17000000, + Testnet = 0xff000000, +} + +export const DEFAULT_CHAIN_ID = ChainId.Mainnet; export const MAX_STRING_LENGTH_BYTES = 128; export const CLARITY_INT_SIZE = 128; export const CLARITY_INT_BYTE_SIZE = 16; @@ -100,14 +118,14 @@ const AnchorModeMap = { }; /** @ignore */ -export function anchorModeFromNameOrValue(mode: AnchorModeName | AnchorMode): AnchorMode { +export function anchorModeFrom(mode: AnchorModeName | AnchorMode): AnchorMode { if (mode in AnchorModeMap) return AnchorModeMap[mode]; throw new Error(`Invalid anchor mode "${mode}", must be one of: ${AnchorModeNames.join(', ')}`); } /** * The transaction version, used so transactions can't be replayed on other networks. - * Similar to the {@link ChainID}. + * Similar to the {@link ChainId}. * Used internally for serializing and deserializing transactions. */ export enum TransactionVersion { @@ -117,6 +135,11 @@ export enum TransactionVersion { export const DEFAULT_TRANSACTION_VERSION = TransactionVersion.Mainnet; +/** @ignore */ +export function whenTransactionVersion(transactionVersion: TransactionVersion) { + return (map: Record): T => map[transactionVersion]; +} + /** * How to treat unspecified transfers of a transaction. * Used for creating transactions. @@ -218,7 +241,7 @@ export enum NonFungibleConditionCode { /** * The type of sender for a post-condition. */ -export enum PostConditionPrincipalID { +export enum PostConditionPrincipalId { Origin = 0x01, Standard = 0x02, Contract = 0x03, diff --git a/packages/transactions/src/contract-abi.ts b/packages/transactions/src/contract-abi.ts index 541b2c703..797e37a09 100644 --- a/packages/transactions/src/contract-abi.ts +++ b/packages/transactions/src/contract-abi.ts @@ -18,8 +18,6 @@ import { NotImplementedError } from './errors'; import { stringAsciiCV, stringUtf8CV } from './clarity/types/stringCV'; import { utf8ToBytes } from '@stacks/common'; -// From https://github.com/blockstack/stacks-blockchain-sidecar/blob/master/src/event-stream/contract-abi.ts - export type ClarityAbiTypeBuffer = { buffer: { length: number } }; export type ClarityAbiTypeStringAscii = { 'string-ascii': { length: number } }; export type ClarityAbiTypeStringUtf8 = { 'string-utf8': { length: number } }; @@ -70,6 +68,70 @@ export enum ClarityAbiTypeId { ClarityAbiTypeTraitReference = 13, } +export type ClarityAbiTypeUnion = + | { id: ClarityAbiTypeId.ClarityAbiTypeUInt128; type: ClarityAbiTypeUInt128 } + | { id: ClarityAbiTypeId.ClarityAbiTypeInt128; type: ClarityAbiTypeInt128 } + | { id: ClarityAbiTypeId.ClarityAbiTypeBool; type: ClarityAbiTypeBool } + | { id: ClarityAbiTypeId.ClarityAbiTypePrincipal; type: ClarityAbiTypePrincipal } + | { id: ClarityAbiTypeId.ClarityAbiTypeTraitReference; type: ClarityAbiTypeTraitReference } + | { id: ClarityAbiTypeId.ClarityAbiTypeNone; type: ClarityAbiTypeNone } + | { id: ClarityAbiTypeId.ClarityAbiTypeBuffer; type: ClarityAbiTypeBuffer } + | { id: ClarityAbiTypeId.ClarityAbiTypeResponse; type: ClarityAbiTypeResponse } + | { id: ClarityAbiTypeId.ClarityAbiTypeOptional; type: ClarityAbiTypeOptional } + | { id: ClarityAbiTypeId.ClarityAbiTypeTuple; type: ClarityAbiTypeTuple } + | { id: ClarityAbiTypeId.ClarityAbiTypeList; type: ClarityAbiTypeList } + | { id: ClarityAbiTypeId.ClarityAbiTypeStringAscii; type: ClarityAbiTypeStringAscii } + | { id: ClarityAbiTypeId.ClarityAbiTypeStringUtf8; type: ClarityAbiTypeStringUtf8 }; + +export interface ClarityAbiFunction { + name: string; + access: 'private' | 'public' | 'read_only'; + args: { + name: string; + type: ClarityAbiType; + }[]; + outputs: { + type: ClarityAbiType; + }; +} + +export interface ClarityAbiVariable { + name: string; + access: 'variable' | 'constant'; + type: ClarityAbiType; +} + +export interface ClarityAbiMap { + name: string; + key: { + name: string; + type: ClarityAbiType; + }[]; + value: { + name: string; + type: ClarityAbiType; + }[]; +} + +export interface ClarityAbiTypeFungibleToken { + name: string; +} + +export interface ClarityAbiTypeNonFungibleToken { + name: string; + type: ClarityAbiType; +} + +export interface ClarityAbi { + functions: ClarityAbiFunction[]; + variables: ClarityAbiVariable[]; + maps: ClarityAbiMap[]; + fungible_tokens: ClarityAbiTypeFungibleToken[]; + non_fungible_tokens: ClarityAbiTypeNonFungibleToken[]; +} + +// From https://github.com/blockstack/stacks-blockchain-sidecar/blob/master/src/event-stream/contract-abi.ts + export const isClarityAbiPrimitive = (val: ClarityAbiType): val is ClarityAbiTypePrimitive => typeof val === 'string'; export const isClarityAbiBuffer = (val: ClarityAbiType): val is ClarityAbiTypeBuffer => @@ -87,21 +149,6 @@ export const isClarityAbiTuple = (val: ClarityAbiType): val is ClarityAbiTypeTup export const isClarityAbiList = (val: ClarityAbiType): val is ClarityAbiTypeList => (val as ClarityAbiTypeList).list !== undefined; -export type ClarityAbiTypeUnion = - | { id: ClarityAbiTypeId.ClarityAbiTypeUInt128; type: ClarityAbiTypeUInt128 } - | { id: ClarityAbiTypeId.ClarityAbiTypeInt128; type: ClarityAbiTypeInt128 } - | { id: ClarityAbiTypeId.ClarityAbiTypeBool; type: ClarityAbiTypeBool } - | { id: ClarityAbiTypeId.ClarityAbiTypePrincipal; type: ClarityAbiTypePrincipal } - | { id: ClarityAbiTypeId.ClarityAbiTypeTraitReference; type: ClarityAbiTypeTraitReference } - | { id: ClarityAbiTypeId.ClarityAbiTypeNone; type: ClarityAbiTypeNone } - | { id: ClarityAbiTypeId.ClarityAbiTypeBuffer; type: ClarityAbiTypeBuffer } - | { id: ClarityAbiTypeId.ClarityAbiTypeResponse; type: ClarityAbiTypeResponse } - | { id: ClarityAbiTypeId.ClarityAbiTypeOptional; type: ClarityAbiTypeOptional } - | { id: ClarityAbiTypeId.ClarityAbiTypeTuple; type: ClarityAbiTypeTuple } - | { id: ClarityAbiTypeId.ClarityAbiTypeList; type: ClarityAbiTypeList } - | { id: ClarityAbiTypeId.ClarityAbiTypeStringAscii; type: ClarityAbiTypeStringAscii } - | { id: ClarityAbiTypeId.ClarityAbiTypeStringUtf8; type: ClarityAbiTypeStringUtf8 }; - export function getTypeUnion(val: ClarityAbiType): ClarityAbiTypeUnion { if (isClarityAbiPrimitive(val)) { if (val === 'uint128') { @@ -218,18 +265,6 @@ export function getTypeString(val: ClarityAbiType): string { } } -export interface ClarityAbiFunction { - name: string; - access: 'private' | 'public' | 'read_only'; - args: { - name: string; - type: ClarityAbiType; - }[]; - outputs: { - type: ClarityAbiType; - }; -} - export function abiFunctionToString(func: ClarityAbiFunction): string { const access = func.access === 'read_only' ? 'read-only' : func.access; return `(define-${access} (${func.name} ${func.args @@ -237,41 +272,6 @@ export function abiFunctionToString(func: ClarityAbiFunction): string { .join(' ')}))`; } -export interface ClarityAbiVariable { - name: string; - access: 'variable' | 'constant'; - type: ClarityAbiType; -} - -export interface ClarityAbiMap { - name: string; - key: { - name: string; - type: ClarityAbiType; - }[]; - value: { - name: string; - type: ClarityAbiType; - }[]; -} - -export interface ClarityAbiTypeFungibleToken { - name: string; -} - -export interface ClarityAbiTypeNonFungibleToken { - name: string; - type: ClarityAbiType; -} - -export interface ClarityAbi { - functions: ClarityAbiFunction[]; - variables: ClarityAbiVariable[]; - maps: ClarityAbiMap[]; - fungible_tokens: ClarityAbiTypeFungibleToken[]; - non_fungible_tokens: ClarityAbiTypeNonFungibleToken[]; -} - function matchType(cv: ClarityValue, abiType: ClarityAbiType): boolean { const union = getTypeUnion(abiType); diff --git a/packages/transactions/src/errors.ts b/packages/transactions/src/errors.ts index 976970cc5..a768b0150 100644 --- a/packages/transactions/src/errors.ts +++ b/packages/transactions/src/errors.ts @@ -21,31 +21,31 @@ export class DeserializationError extends TransactionError { } } -/** - * Thrown when `NoEstimateAvailable` is received as an error reason from a - * Stacks node. The Stacks node has not seen this kind of contract-call before, - * and it cannot provide an estimate yet. - * @see https://docs.hiro.so/api#tag/Fees/operation/post_fee_transaction - */ -export class NoEstimateAvailableError extends TransactionError { +export class NotImplementedError extends TransactionError { constructor(message: string) { super(message); } } -export class NotImplementedError extends TransactionError { +export class SigningError extends TransactionError { constructor(message: string) { super(message); } } -export class SigningError extends TransactionError { +export class VerificationError extends TransactionError { constructor(message: string) { super(message); } } -export class VerificationError extends TransactionError { +/** + * Thrown when `NoEstimateAvailable` is received as an error reason from a + * Stacks node. The Stacks node has not seen this kind of contract-call before, + * and it cannot provide an estimate yet. + * @see https://docs.hiro.so/api#tag/Fees/operation/post_fee_transaction + */ +export class NoEstimateAvailableError extends TransactionError { constructor(message: string) { super(message); } diff --git a/packages/transactions/src/fetch.ts b/packages/transactions/src/fetch.ts new file mode 100644 index 000000000..2e969f650 --- /dev/null +++ b/packages/transactions/src/fetch.ts @@ -0,0 +1,387 @@ +import { + ApiParam, + HIRO_MAINNET_URL, + bytesToHex, + createFetchFn, + validateHash256, + with0x, +} from '@stacks/common'; +import { deriveDefaultUrl } from './api'; +import { estimateTransactionByteLength } from './builders'; +import { ClarityValue, NoneCV, deserializeCV, serializeCV } from './clarity'; +import { NoEstimateAvailableError } from './errors'; +import { serializePayload } from './payload'; +import { StacksTransaction, deriveNetwork } from './transaction'; +import { + FeeEstimateResponse, + FeeEstimation, + TxBroadcastResult, + TxBroadcastResultOk, + TxBroadcastResultRejected, +} from './types'; +import { cvToHex, parseReadOnlyResponse } from './utils'; + +export const BROADCAST_PATH = '/v2/transactions'; +export const TRANSFER_FEE_ESTIMATE_PATH = '/v2/fees/transfer'; +export const TRANSACTION_FEE_ESTIMATE_PATH = '/v2/fees/transaction'; +export const ACCOUNT_PATH = '/v2/accounts'; +export const CONTRACT_ABI_PATH = '/v2/contracts/interface'; +export const READONLY_FUNCTION_CALL_PATH = '/v2/contracts/call-read'; +export const MAP_ENTRY_PATH = '/v2/map_entry'; + +/** Creates a API-like object, which can be used without circular dependencies @ignore */ +function defaultApiLike() { + return { + // todo: do we want network here as well? + url: HIRO_MAINNET_URL, + fetch: createFetchFn(), + }; +} + +/** + * Broadcast a serialized transaction to a Stacks node (which will validate and forward to the network). + * @param opts.transaction - The transaction to broadcast + * @param opts.attachment - Optional attachment encoded as a hex string + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @returns A Promise that resolves to a {@link TxBroadcastResult} object + */ +export async function broadcastTransaction({ + transaction: txOpt, + attachment: attachOpt, + api: apiOpt, +}: { + /** The transaction to broadcast */ + transaction: StacksTransaction; + /** Optional attachment in bytes or encoded as a hex string */ + attachment?: Uint8Array | string; +} & ApiParam): Promise { + const tx = bytesToHex(txOpt.serialize()); + const attachment = attachOpt + ? typeof attachOpt === 'string' + ? attachOpt + : bytesToHex(attachOpt) + : undefined; + const json = attachOpt ? { tx, attachment } : { tx }; + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(json), + }; + + const api = apiOpt ?? { + url: deriveDefaultUrl(deriveNetwork(txOpt)), + fetch: createFetchFn(), + }; + const url = `${api.url}${BROADCAST_PATH}`; + const response = await api.fetch(url, options); + + if (!response.ok) { + try { + return (await response.json()) as TxBroadcastResultRejected; + } catch (e) { + throw Error('Failed to broadcast transaction (unable to parse node response).', { cause: e }); + } + } + + const text = await response.text(); + const txid = text.replace(/["]+/g, ''); // Replace extra quotes around txid string + if (!validateHash256(txid)) throw new Error(text); + + return { txid } as TxBroadcastResultOk; +} + +/** + * Lookup the nonce for an address from a core node + * @param opts.address - The Stacks address to look up the next nonce for + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @return A promise that resolves to an integer + */ +export async function getNonce({ + address, + api: apiOpt, +}: { + /** The Stacks address to look up the next nonce for */ + address: string; +} & ApiParam): Promise { + // todo: could derive the network from the address and use as default if no apiOd + + const api = apiOpt ?? defaultApiLike(); + const url = `${api.url}${ACCOUNT_PATH}/${address}?proof=0`; + const response = await api.fetch(url); + + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error( + `Error fetching nonce. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` + ); + } + + const json = (await response.json()) as { nonce: string }; + return BigInt(json.nonce); +} + +/** + * @deprecated Use the new {@link estimateTransaction} function instead. + * + * Estimate the total transaction fee in microstacks for a token transfer + * + * ⚠ Only sensible for token transfer transactions! + * @param opts.transaction - The token transfer transaction to estimate fees for + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @return A promise that resolves to number of microstacks per byte + */ +export async function estimateTransfer({ + transaction: txOpt, + api: apiOpt, +}: { + /** The token transfer transaction to estimate fees for */ + transaction: StacksTransaction; +} & ApiParam): Promise { + const api = apiOpt ?? { + url: deriveDefaultUrl(deriveNetwork(txOpt)), + fetch: createFetchFn(), + }; + const url = `${api.url}${TRANSFER_FEE_ESTIMATE_PATH}`; + const response = await api.fetch(url, { + headers: { Accept: 'application/text' }, + }); + + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error( + `Error estimating transfer fee. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` + ); + } + + const feeRateResult = await response.text(); + const txBytes = BigInt(Math.ceil(txOpt.serialize().byteLength)); + const feeRate = BigInt(feeRateResult); + return feeRate * txBytes; +} + +/** + * Estimate the total transaction fee in microstacks for a Stacks transaction + * @param opts.payload - The transaction to estimate fees for + * @param opts.estimatedLength - Optional estimation of the final length (in + * bytes) of the transaction, including any post-conditions and signatures + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @return A promise that resolves to FeeEstimate + */ +export async function estimateTransaction({ + payload, + estimatedLength, + api: apiOpt, +}: { + payload: string; + estimatedLength?: number; +} & ApiParam): Promise<[FeeEstimation, FeeEstimation, FeeEstimation]> { + const json = { + transaction_payload: payload, + estimated_len: estimatedLength, + }; + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(json), + }; + + const api = apiOpt ?? defaultApiLike(); + const url = `${api.url}${TRANSACTION_FEE_ESTIMATE_PATH}`; + const response = await api.fetch(url, options); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + + if (body?.reason === 'NoEstimateAvailable') { + throw new NoEstimateAvailableError(body?.reason_data?.message ?? ''); + } + + throw new Error( + `Error estimating transaction fee. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${body}"` + ); + } + + const data: FeeEstimateResponse = await response.json(); + return data.estimations; +} + +/** + * Estimates the fee using {@link estimateTransaction}, but retries to estimate + * with {@link estimateTransfer} as a fallback if does not get an estimation due + * to the {@link NoEstimateAvailableError} error. + * @param opts.transaction - The transaction to estimate fees for + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + */ +export async function estimateFee({ + transaction: txOpt, + api: apiOpt, +}: { + transaction: StacksTransaction; +} & ApiParam): Promise { + const api = apiOpt ?? { + url: deriveDefaultUrl(deriveNetwork(txOpt)), + fetch: createFetchFn(), + }; + + try { + const estimatedLength = estimateTransactionByteLength(txOpt); + return ( + await estimateTransaction({ + payload: bytesToHex(serializePayload(txOpt.payload)), + estimatedLength, + api, + }) + )[1].fee; + } catch (error) { + if (!(error instanceof NoEstimateAvailableError)) throw error; + return await estimateTransfer({ transaction: txOpt, api }); + } +} + +/** + * Fetch a contract's ABI + * @param opts.address - The contracts address + * @param opts.contractName - The contracts name + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @returns A promise that resolves to a ClarityAbi if the operation succeeds + */ +export async function getAbi({ + contractAddress: address, + contractName: name, + api: apiOpt, +}: { + contractAddress: string; + contractName: string; +} & ApiParam): Promise { + const api = apiOpt ?? defaultApiLike(); + const url = `${api.url}${CONTRACT_ABI_PATH}/${address}/${name}`; + const response = await api.fetch(url); + + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error( + `Error fetching contract ABI for contract "${name}" at address ${address}. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` + ); + } + + return (await response.json()) as ClarityAbi; +} + +/** + * Calls a function as read-only from a contract interface. + * It is not necessary that the function is defined as read-only in the contract + * @param opts.contractName - The contract name + * @param opts.contractAddress - The contract address + * @param opts.functionName - The contract function name + * @param opts.functionArgs - The contract function arguments + * @param opts.senderAddress - The address of the (simulated) sender + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @return Returns an object with a status bool (okay) and a result string that + * is a serialized clarity value in hex format. + */ +export async function callReadOnlyFunction({ + contractName, + contractAddress, + functionName, + functionArgs, + senderAddress, + api: apiOpt, +}: { + contractName: string; + contractAddress: string; + functionName: string; + functionArgs: ClarityValue[]; + /** address of the sender */ + senderAddress: string; +} & ApiParam): Promise { + const json = { + sender: senderAddress, + arguments: functionArgs.map(arg => cvToHex(arg)), + }; + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(json), + }; + + const name = encodeURIComponent(functionName); + + const api = apiOpt ?? defaultApiLike(); + const url = `${api.url}${READONLY_FUNCTION_CALL_PATH}/${contractAddress}/${contractName}/${name}`; + const response = await api.fetch(url, options); + + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error( + `Error calling read-only function. Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` + ); + } + + return await response.json().then(parseReadOnlyResponse); +} + +/** + * Fetch data from a contract data map. + * @param opts.contractAddress - The contract address + * @param opts.contractName - The contract name + * @param opts.mapName - The map variable name + * @param opts.mapKey - The key of the map entry to look up + * @param opts.api - Optional API info (`.url` & `.fetch`) used for fetch call + * @returns Promise that resolves to a ClarityValue if the operation succeeds. + * Resolves to NoneCV if the map does not contain the given key, if the map does not exist, or if the contract prinicipal does not exist + */ +export async function getContractMapEntry({ + contractAddress, + contractName, + mapName, + mapKey, + api: apiOpt, +}: { + contractAddress: string; + contractName: string; + mapName: string; + mapKey: ClarityValue; +} & ApiParam): Promise { + const keyHex = with0x(bytesToHex(serializeCV(mapKey))); + + const options = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(keyHex), // endpoint expects a JSON string atom (quote wrapped string) + }; + + const api = apiOpt ?? defaultApiLike(); + const url = `${api.url}${MAP_ENTRY_PATH}/${contractAddress}/${contractName}/${mapName}?proof=0`; + const response = await api.fetch(url, options); + + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error( + `Error fetching map entry for map "${mapName}" in contract "${contractName}" at address ${contractAddress}, using map key "${keyHex}". Response ${response.status}: ${response.statusText}. Attempted to fetch ${url} and failed with the message: "${msg}"` + ); + } + + const json: { data?: string } = await response.json(); + if (!json.data) { + throw new Error( + `Error fetching map entry for map "${mapName}" in contract "${contractName}" at address ${contractAddress}, using map key "${keyHex}". Response ${ + response.status + }: ${response.statusText}. Attempted to fetch ${ + api.url + } and failed with the response: "${JSON.stringify(json)}"` + ); + } + + try { + return deserializeCV(json.data); + } catch (error) { + throw new Error(`Error deserializing Clarity value "${json.data}": ${error}`); + } +} + +import { ClarityAbi } from './contract-abi'; diff --git a/packages/transactions/src/index.ts b/packages/transactions/src/index.ts index 7bea2f327..17a69e789 100644 --- a/packages/transactions/src/index.ts +++ b/packages/transactions/src/index.ts @@ -8,7 +8,7 @@ export { isSingleSig, } from './authorization'; export * from './builders'; -export { BytesReader as BytesReader } from './bytesReader'; +export { BytesReader } from './bytesReader'; /** * ### `Cl.` Clarity Value Namespace * The `Cl` namespace is provided as a convenience to build/parse Clarity Value objects. @@ -71,6 +71,7 @@ export { isSmartContractPayload, isTokenTransferPayload, serializePayload, + Payload, } from './payload'; /** * ### `Pc.` Post Condition Builder @@ -101,3 +102,5 @@ export * from './structuredDataSignature'; export { StacksTransaction, deserializeTransaction } from './transaction'; export * from './types'; export * from './utils'; +export * from './fetch'; +export * from './network'; diff --git a/packages/transactions/src/keys.ts b/packages/transactions/src/keys.ts index 4fc0953cc..76e0ebfa5 100644 --- a/packages/transactions/src/keys.ts +++ b/packages/transactions/src/keys.ts @@ -14,8 +14,8 @@ import { hexToBytes, intToHex, parseRecoverableSignatureVrs, - privateKeyToBytes, PRIVATE_KEY_COMPRESSED_LENGTH, + privateKeyToBytes, signatureRsvToVrs, signatureVrsToRsv, } from '@stacks/common'; diff --git a/packages/transactions/src/network.ts b/packages/transactions/src/network.ts new file mode 100644 index 000000000..61e836fca --- /dev/null +++ b/packages/transactions/src/network.ts @@ -0,0 +1,51 @@ +import { ChainId, PeerNetworkId, TransactionVersion } from './constants'; + +export interface StacksNetwork { + chainId: number; + transactionVersion: number; // todo: txVersion better? + peerNetworkId: number; + magicBytes: string; + // todo: add check32 character bytes string +} + +export const STACKS_MAINNET: StacksNetwork = { + chainId: ChainId.Mainnet, + transactionVersion: TransactionVersion.Mainnet, + peerNetworkId: PeerNetworkId.Mainnet, + magicBytes: 'X2', // todo: comment bytes version of magic bytes +}; + +export const STACKS_TESTNET: StacksNetwork = { + chainId: ChainId.Testnet, + transactionVersion: TransactionVersion.Testnet, + peerNetworkId: PeerNetworkId.Testnet, + magicBytes: 'T2', // todo: comment bytes version of magic bytes +}; + +export const STACKS_DEVNET: StacksNetwork = { + ...STACKS_TESTNET, + magicBytes: 'id', // todo: comment bytes version of magic bytes +}; + +/** @ignore internal */ +export const StacksNetworks = ['mainnet', 'testnet', 'devnet', 'mocknet'] as const; +/** The enum-style names of different common Stacks networks */ +export type StacksNetworkName = (typeof StacksNetworks)[number]; + +export function networkFromName(name: StacksNetworkName) { + switch (name) { + case 'mainnet': + return STACKS_MAINNET; + case 'testnet': + return STACKS_TESTNET; + case 'devnet': + return STACKS_DEVNET; + default: + throw new Error(`Unknown network name: ${name}`); + } +} + +export function networkFrom(network: StacksNetworkName | StacksNetwork) { + if (typeof network === 'string') return networkFromName(network); + return network; +} diff --git a/packages/transactions/src/pc.ts b/packages/transactions/src/pc.ts index 2ca20eccf..0e60f6dca 100644 --- a/packages/transactions/src/pc.ts +++ b/packages/transactions/src/pc.ts @@ -30,7 +30,7 @@ type AddressString = string; type ContractIdString = `${string}.${string}`; /** - * An asset identifier string given as `::` aka `.::` + * An asset name string given as `::` aka `.::` */ type NftString = `${ContractIdString}::${string}`; diff --git a/packages/transactions/src/postcondition-types.ts b/packages/transactions/src/postcondition-types.ts index 74c95c787..ab24d6701 100644 --- a/packages/transactions/src/postcondition-types.ts +++ b/packages/transactions/src/postcondition-types.ts @@ -2,7 +2,7 @@ import { FungibleConditionCode, MAX_STRING_LENGTH_BYTES, NonFungibleConditionCode, - PostConditionPrincipalID, + PostConditionPrincipalId, PostConditionType, StacksMessageType, } from './constants'; @@ -13,13 +13,13 @@ import { exceedsMaxLengthBytes } from './utils'; export interface StandardPrincipal { readonly type: StacksMessageType.Principal; - readonly prefix: PostConditionPrincipalID.Standard; + readonly prefix: PostConditionPrincipalId.Standard; readonly address: Address; } export interface ContractPrincipal { readonly type: StacksMessageType.Principal; - readonly prefix: PostConditionPrincipalID.Contract; + readonly prefix: PostConditionPrincipalId.Contract; readonly address: Address; readonly contractName: LengthPrefixedString; } @@ -144,7 +144,7 @@ export function createContractPrincipal( const name = createLPString(contractName); return { type: StacksMessageType.Principal, - prefix: PostConditionPrincipalID.Contract, + prefix: PostConditionPrincipalId.Contract, address: addr, contractName: name, }; @@ -154,7 +154,7 @@ export function createStandardPrincipal(addressString: string): StandardPrincipa const addr = createAddress(addressString); return { type: StacksMessageType.Principal, - prefix: PostConditionPrincipalID.Standard, + prefix: PostConditionPrincipalId.Standard, address: addr, }; } diff --git a/packages/transactions/src/transaction.ts b/packages/transactions/src/transaction.ts index d5dd3d752..fc3fb1e6a 100644 --- a/packages/transactions/src/transaction.ts +++ b/packages/transactions/src/transaction.ts @@ -8,16 +8,17 @@ import { } from '@stacks/common'; import { AnchorMode, - anchorModeFromNameOrValue, + anchorModeFrom, AnchorModeName, AuthType, - ChainID, + ChainId, DEFAULT_CHAIN_ID, PayloadType, PostConditionMode, PubKeyEncoding, StacksMessageType, TransactionVersion, + whenTransactionVersion, } from './constants'; import { @@ -47,10 +48,11 @@ import { isCompressed, StacksPrivateKey, StacksPublicKey } from './keys'; import { BytesReader } from './bytesReader'; import { SerializationError, SigningError } from './errors'; +import { STACKS_MAINNET, STACKS_TESTNET } from './network'; export class StacksTransaction { version: TransactionVersion; - chainId: ChainID; + chainId: ChainId; auth: Authorization; anchorMode: AnchorMode; payload: Payload; @@ -64,7 +66,7 @@ export class StacksTransaction { postConditions?: LengthPrefixedList, postConditionMode?: PostConditionMode, anchorMode?: AnchorModeName | AnchorMode, - chainId?: ChainID + chainId?: ChainId ) { this.version = version; this.auth = auth; @@ -81,7 +83,7 @@ export class StacksTransaction { this.postConditions = postConditions ?? createLPList([]); if (anchorMode) { - this.anchorMode = anchorModeFromNameOrValue(anchorMode); + this.anchorMode = anchorModeFrom(anchorMode); } else { switch (payload.payloadType) { case PayloadType.Coinbase: @@ -300,3 +302,11 @@ export function deserializeTransaction(tx: string | Uint8Array | BytesReader) { chainId ); } + +/** @ignore */ +export function deriveNetwork(transaction: StacksTransaction) { + return whenTransactionVersion(transaction.version)({ + [TransactionVersion.Mainnet]: STACKS_MAINNET, + [TransactionVersion.Testnet]: STACKS_TESTNET, + }); +} diff --git a/packages/transactions/src/types.ts b/packages/transactions/src/types.ts index ad1782429..be7494129 100644 --- a/packages/transactions/src/types.ts +++ b/packages/transactions/src/types.ts @@ -14,7 +14,7 @@ import { AddressVersion, TransactionVersion, StacksMessageType, - PostConditionPrincipalID, + PostConditionPrincipalId, PostConditionType, FungibleConditionCode, NonFungibleConditionCode, @@ -205,18 +205,18 @@ export function serializePrincipal(principal: PostConditionPrincipal): Uint8Arra const bytesArray = []; bytesArray.push(principal.prefix); bytesArray.push(serializeAddress(principal.address)); - if (principal.prefix === PostConditionPrincipalID.Contract) { + if (principal.prefix === PostConditionPrincipalId.Contract) { bytesArray.push(serializeLPString(principal.contractName)); } return concatArray(bytesArray); } export function deserializePrincipal(bytesReader: BytesReader): PostConditionPrincipal { - const prefix = bytesReader.readUInt8Enum(PostConditionPrincipalID, n => { + const prefix = bytesReader.readUInt8Enum(PostConditionPrincipalId, n => { throw new DeserializationError(`Unexpected Principal payload type: ${n}`); }); const address = deserializeAddress(bytesReader); - if (prefix === PostConditionPrincipalID.Standard) { + if (prefix === PostConditionPrincipalId.Standard) { return { type: StacksMessageType.Principal, prefix, address } as StandardPrincipal; } const contractName = deserializeLPString(bytesReader); @@ -438,3 +438,160 @@ export function deserializePostCondition(bytesReader: BytesReader): PostConditio }; } } + +export type BaseRejection = { + error: string; + reason: string; + txid: string; +}; + +export type SerializationRejection = { + reason: 'Serialization'; + reason_data: { + message: string; + }; +} & BaseRejection; + +export type DeserializationRejection = { + reason: 'Deserialization'; + reason_data: { + message: string; + }; +} & BaseRejection; + +export type SignatureValidationRejection = { + reason: 'SignatureValidation'; + reason_data: { + message: string; + }; +} & BaseRejection; + +export type BadNonceRejection = { + reason: 'BadNonce'; + reason_data: { + expected: number; + actual: number; + is_origin: boolean; + principal: boolean; + }; +} & BaseRejection; + +export type FeeTooLowRejection = { + reason: 'FeeTooLow'; + reason_data: { + expected: number; + actual: number; + }; +} & BaseRejection; + +export type NotEnoughFundsRejection = { + reason: 'NotEnoughFunds'; + reason_data: { + expected: string; + actual: string; + }; +} & BaseRejection; + +export type NoSuchContractRejection = { + reason: 'NoSuchContract'; +} & BaseRejection; + +export type NoSuchPublicFunctionRejection = { + reason: 'NoSuchPublicFunction'; +}; + +export type BadFunctionArgumentRejection = { + reason: 'BadFunctionArgument'; + reason_data: { + message: string; + }; +} & BaseRejection; + +export type ContractAlreadyExistsRejection = { + reason: 'ContractAlreadyExists'; + reason_data: { + contract_identifier: string; + }; +} & BaseRejection; + +export type PoisonMicroblocksDoNotConflictRejection = { + reason: 'PoisonMicroblocksDoNotConflict'; +} & BaseRejection; + +export type PoisonMicroblockHasUnknownPubKeyHashRejection = { + reason: 'PoisonMicroblockHasUnknownPubKeyHash'; +} & BaseRejection; + +export type PoisonMicroblockIsInvalidRejection = { + reason: 'PoisonMicroblockIsInvalid'; +} & BaseRejection; + +export type BadAddressVersionByteRejection = { + reason: 'BadAddressVersionByte'; +} & BaseRejection; + +export type NoCoinbaseViaMempoolRejection = { + reason: 'NoCoinbaseViaMempool'; +} & BaseRejection; + +export type ServerFailureNoSuchChainTipRejection = { + reason: 'ServerFailureNoSuchChainTip'; +} & BaseRejection; + +export type ServerFailureDatabaseRejection = { + reason: 'ServerFailureDatabase'; + reason_data: { + message: string; + }; +} & BaseRejection; + +export type ServerFailureOtherRejection = { + reason: 'ServerFailureOther'; + reason_data: { + message: string; + }; +} & BaseRejection; + +export type TxBroadcastResultOk = { + txid: string; +}; + +export type TxBroadcastResultRejected = + | SerializationRejection + | DeserializationRejection + | SignatureValidationRejection + | BadNonceRejection + | FeeTooLowRejection + | NotEnoughFundsRejection + | NoSuchContractRejection + | NoSuchPublicFunctionRejection + | BadFunctionArgumentRejection + | ContractAlreadyExistsRejection + | PoisonMicroblocksDoNotConflictRejection + | PoisonMicroblockHasUnknownPubKeyHashRejection + | PoisonMicroblockIsInvalidRejection + | BadAddressVersionByteRejection + | NoCoinbaseViaMempoolRejection + | ServerFailureNoSuchChainTipRejection + | ServerFailureDatabaseRejection + | ServerFailureOtherRejection; + +export type TxBroadcastResult = TxBroadcastResultOk | TxBroadcastResultRejected; + +export interface FeeEstimation { + fee: number; + fee_rate: number; +} + +export interface FeeEstimateResponse { + cost_scalar_change_by_byte: bigint; + estimated_cost: { + read_count: bigint; + read_length: bigint; + runtime: bigint; + write_count: bigint; + write_length: bigint; + }; + estimated_cost_scalar: bigint; + estimations: [FeeEstimation, FeeEstimation, FeeEstimation]; +} diff --git a/packages/transactions/src/utils.ts b/packages/transactions/src/utils.ts index 85a7a3804..1acd0112b 100644 --- a/packages/transactions/src/utils.ts +++ b/packages/transactions/src/utils.ts @@ -2,7 +2,7 @@ import { ripemd160 } from '@noble/hashes/ripemd160'; import { sha256 } from '@noble/hashes/sha256'; import { sha512_256 } from '@noble/hashes/sha512'; import { utils } from '@noble/secp256k1'; -import { bytesToHex, concatArray, concatBytes, utf8ToBytes, with0x } from '@stacks/common'; +import { bytesToHex, concatArray, concatBytes, utf8ToBytes } from '@stacks/common'; import { c32addressDecode } from 'c32check'; import lodashCloneDeep from 'lodash.clonedeep'; import { ClarityValue, deserializeCV, serializeCV } from './clarity'; @@ -155,13 +155,13 @@ export function cvToHex(cv: ClarityValue) { export function hexToCV(hex: string) { return deserializeCV(hex); } + /** * Read only function response object * * @param {Boolean} okay - the status of the response * @param {string} result - serialized hex clarity value */ - export interface ReadOnlyFunctionSuccessResponse { okay: true; result: string; @@ -185,18 +185,11 @@ export const parseReadOnlyResponse = (response: ReadOnlyFunctionResponse): Clari throw new Error(response.cause); }; -export const validateStacksAddress = (stacksAddress: string): boolean => { +export const validateStacksAddress = (address: string): boolean => { try { - c32addressDecode(stacksAddress); + c32addressDecode(address); return true; } catch (e) { return false; } }; - -export const validateTxId = (txid: string): boolean => { - if (txid === 'success') return true; // Bypass fetchMock tests // todo: move this line into mocks in test files - const value = with0x(txid).toLowerCase(); - if (value.length !== 66) return false; - return with0x(BigInt(value).toString(16).padStart(64, '0')) === value; -}; diff --git a/packages/transactions/tests/abi.test.ts b/packages/transactions/tests/abi.test.ts index ff0ca3a64..8c2940328 100644 --- a/packages/transactions/tests/abi.test.ts +++ b/packages/transactions/tests/abi.test.ts @@ -1,7 +1,6 @@ +import { utf8ToBytes } from '@stacks/common'; import { readFileSync } from 'fs'; import path from 'path'; -import { createContractCallPayload } from '../src/payload'; - import { bufferCVFromString, contractPrincipalCV, @@ -19,16 +18,14 @@ import { tupleCV, uintCV, } from '../src/clarity'; - import { - abiFunctionToString, ClarityAbi, ClarityAbiType, + abiFunctionToString, parseToCV, validateContractCall, } from '../src/contract-abi'; - -import { utf8ToBytes } from '@stacks/common'; +import { createContractCallPayload } from '../src/payload'; const TEST_ABI: ClarityAbi = JSON.parse( readFileSync(path.join(__dirname, './abi/test-abi.json')).toString() diff --git a/packages/transactions/tests/api.test.ts b/packages/transactions/tests/api.test.ts new file mode 100644 index 000000000..b7336c487 --- /dev/null +++ b/packages/transactions/tests/api.test.ts @@ -0,0 +1,22 @@ +import { DEVNET_URL, HIRO_MAINNET_URL, HIRO_TESTNET_URL } from '@stacks/common'; +import { StacksNodeApi } from '../src/api'; +import { STACKS_DEVNET, STACKS_MAINNET, STACKS_TESTNET } from '../src'; + +describe('setting StacksApi URL', () => { + test.each([ + { network: STACKS_MAINNET, url: HIRO_MAINNET_URL }, + { network: STACKS_TESTNET, url: HIRO_TESTNET_URL }, + { network: STACKS_DEVNET, url: DEVNET_URL }, + ])('the api class determines the correct url for each network object', ({ network, url }) => { + const api = new StacksNodeApi({ network }); + expect(api.url).toEqual(url); + }); +}); + +// todo: still needed? +// it('uses the correct constructor for stacks network from name strings', () => { +// expect(StacksNetwork.fromName('mainnet').constructor.toString()).toContain('StacksMainnet'); +// expect(StacksNetwork.fromName('testnet').constructor.toString()).toContain('StacksTestnet'); +// expect(StacksNetwork.fromName('devnet').constructor.toString()).toContain('StacksMocknet'); // devnet is an alias for mocknet +// expect(StacksNetwork.fromName('mocknet').constructor.toString()).toContain('StacksMocknet'); +// }); diff --git a/packages/transactions/tests/builder.test.ts b/packages/transactions/tests/builder.test.ts index b6d7eefd3..1fb787111 100644 --- a/packages/transactions/tests/builder.test.ts +++ b/packages/transactions/tests/builder.test.ts @@ -1,58 +1,70 @@ -import { bytesToHex, utf8ToBytes } from '@stacks/common'; import { + HIRO_MAINNET_URL, + HIRO_TESTNET_URL, + bytesToHex, createApiKeyMiddleware, createFetchFn, - StacksMainnet, - StacksTestnet, -} from '@stacks/network'; + utf8ToBytes, +} from '@stacks/common'; import * as fs from 'fs'; import fetchMock from 'jest-fetch-mock'; import { + ACCOUNT_PATH, + BROADCAST_PATH, + BadNonceRejection, + CONTRACT_ABI_PATH, + ClarityAbi, + READONLY_FUNCTION_CALL_PATH, + STACKS_TESTNET, + TRANSACTION_FEE_ESTIMATE_PATH, + TxBroadcastResult, + TxBroadcastResultOk, + TxBroadcastResultRejected, + broadcastTransaction, + callReadOnlyFunction, + estimateFee, + estimateTransaction, + getContractMapEntry, + getNonce, +} from '../src'; +import { StacksNodeApi } from '../src/api'; +import { + MultiSigSpendingCondition, + SingleSigSpendingCondition, + SponsoredAuthorization, + StandardAuthorization, createSingleSigSpendingCondition, createSponsoredAuth, emptyMessageSignature, isSingleSig, - MultiSigSpendingCondition, nextSignature, - SingleSigSpendingCondition, - SponsoredAuthorization, - StandardAuthorization, } from '../src/authorization'; import { - broadcastTransaction, - callReadOnlyFunction, - estimateTransaction, + SignedTokenTransferOptions, estimateTransactionByteLength, - estimateTransactionFeeWithFallback, - getContractMapEntry, - getNonce, makeContractCall, makeContractDeploy, makeContractFungiblePostCondition, makeContractNonFungiblePostCondition, makeContractSTXPostCondition, + makeSTXTokenTransfer, makeStandardFungiblePostCondition, makeStandardNonFungiblePostCondition, makeStandardSTXPostCondition, - makeSTXTokenTransfer, makeUnsignedContractCall, makeUnsignedContractDeploy, makeUnsignedSTXTokenTransfer, - SignedTokenTransferOptions, sponsorTransaction, - TxBroadcastResult, - TxBroadcastResultOk, - TxBroadcastResultRejected, } from '../src/builders'; import { BytesReader } from '../src/bytesReader'; import { + ClarityType, + UIntCV, bufferCV, bufferCVFromString, - ClarityType, noneCV, serializeCV, standardPrincipalCV, - UIntCV, uintCV, } from '../src/clarity'; import { principalCV } from '../src/clarity/types/principalCV'; @@ -71,18 +83,17 @@ import { TransactionVersion, TxRejectedReason, } from '../src/constants'; -import { ClarityAbi } from '../src/contract-abi'; import { createStacksPrivateKey, isCompressed, pubKeyfromPrivKey, publicKeyToString, } from '../src/keys'; -import { createTokenTransferPayload, serializePayload, TokenTransferPayload } from '../src/payload'; +import { TokenTransferPayload, createTokenTransferPayload, serializePayload } from '../src/payload'; import { createAssetInfo } from '../src/postcondition-types'; import { createTransactionAuthField } from '../src/signature'; import { TransactionSigner } from '../src/signer'; -import { deserializeTransaction, StacksTransaction } from '../src/transaction'; +import { StacksTransaction, deserializeTransaction } from '../src/transaction'; import { cloneDeep } from '../src/utils'; function setSignature( @@ -108,15 +119,15 @@ beforeEach(() => { }); test('API key middleware - get nonce', async () => { - const senderAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + const address = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; const apiKey = '1234-my-api-key-example'; - const fetchFn = createFetchFn(createApiKeyMiddleware({ apiKey })); - const network = new StacksMainnet({ fetchFn }); + const fetch = createFetchFn(createApiKeyMiddleware({ apiKey })); + const api = new StacksNodeApi({ fetch }); fetchMock.mockOnce(`{"balance": "0", "nonce": "123"}`); - const fetchNonce = await getNonce(senderAddress, network); + const fetchNonce = await getNonce({ address, api }); expect(fetchNonce).toBe(123n); expect(fetchMock.mock.calls.length).toEqual(1); expect(fetchMock.mock.calls[0][0]).toEqual( @@ -272,7 +283,7 @@ test('Make STX token transfer with testnet', async () => { senderKey, fee, nonce, - network: new StacksTestnet(), + network: STACKS_TESTNET, memo: memo, anchorMode: AnchorMode.Any, }); @@ -722,7 +733,7 @@ test('Make versioned smart contract deploy', async () => { senderKey, fee, nonce, - network: new StacksTestnet(), + network: STACKS_TESTNET, anchorMode: AnchorMode.Any, clarityVersion: ClarityVersion.Clarity2, }); @@ -749,7 +760,7 @@ test('Make smart contract deploy (defaults to versioned smart contract, as of 2. senderKey, fee, nonce, - network: new StacksTestnet(), + network: STACKS_TESTNET, anchorMode: AnchorMode.Any, }); expect(() => transaction.verifyOrigin()).not.toThrow(); @@ -797,7 +808,7 @@ test('Make smart contract deploy unsigned', async () => { publicKey, fee, nonce, - network: new StacksTestnet(), + network: STACKS_TESTNET, anchorMode: AnchorMode.Any, }); @@ -835,7 +846,7 @@ test('make a multi-sig contract deploy', async () => { signerKeys: privKeyStrings, fee, nonce, - network: new StacksTestnet(), + network: STACKS_TESTNET, anchorMode: AnchorMode.Any, }); expect(() => transaction.verifyOrigin()).not.toThrow(); @@ -859,7 +870,7 @@ test('Make smart contract deploy signed', async () => { senderKey, fee, nonce, - network: new StacksTestnet(), + network: STACKS_TESTNET, anchorMode: AnchorMode.Any, }); expect(() => transaction.verifyOrigin()).not.toThrow(); @@ -892,7 +903,7 @@ test('Make contract-call', async () => { senderKey, fee, nonce: 1, - network: new StacksTestnet(), + network: STACKS_TESTNET, anchorMode: AnchorMode.Any, }); expect(() => transaction.verifyOrigin()).not.toThrow(); @@ -987,7 +998,7 @@ test('Make contract-call with post conditions', async () => { senderKey, fee, nonce: 1, - network: new StacksTestnet(), + network: STACKS_TESTNET, postConditions, postConditionMode: PostConditionMode.Deny, anchorMode: AnchorMode.Any, @@ -1033,7 +1044,7 @@ test('Make contract-call with post condition allow mode', async () => { senderKey, fee, nonce: 1, - network: new StacksTestnet(), + network: STACKS_TESTNET, postConditionMode: PostConditionMode.Allow, anchorMode: AnchorMode.Any, }); @@ -1066,7 +1077,7 @@ test('addSignature to an unsigned contract call transaction', async () => { publicKey, fee, nonce: 1, - network: new StacksTestnet(), + network: STACKS_TESTNET, postConditionMode: PostConditionMode.Allow, anchorMode: AnchorMode.Any, }); @@ -1111,7 +1122,7 @@ test('make a multi-sig contract call', async () => { signerKeys: privKeyStrings, fee, nonce: 1, - network: new StacksTestnet(), + network: STACKS_TESTNET, postConditionMode: PostConditionMode.Allow, anchorMode: AnchorMode.Any, }); @@ -1172,31 +1183,31 @@ test('Estimate transaction transfer fee', async () => { fetchMock.mockOnce(mockedResponse); - const mainnet = new StacksMainnet(); - const resultEstimateFee = await estimateTransaction( - transaction.payload, - transactionByteLength, - mainnet - ); + const mainnet = new StacksNodeApi(); + const resultEstimateFee = await estimateTransaction({ + payload: bytesToHex(serializePayload(transaction.payload)), + estimatedLength: transactionByteLength, + api: mainnet, + }); fetchMock.mockOnce(mockedResponse); - const testnet = new StacksTestnet(); - const resultEstimateFee2 = await estimateTransaction( - transaction.payload, - transactionByteLength, - testnet - ); + const testnet = new StacksNodeApi({ network: STACKS_TESTNET }); + const resultEstimateFee2 = await estimateTransaction({ + payload: bytesToHex(serializePayload(transaction.payload)), + estimatedLength: transactionByteLength, + api: testnet, + }); expect(fetchMock.mock.calls.length).toEqual(2); - expect(fetchMock.mock.calls[0][0]).toEqual(mainnet.getTransactionFeeEstimateApiUrl()); + expect(fetchMock.mock.calls[0][0]).toEqual(`${HIRO_MAINNET_URL}${TRANSACTION_FEE_ESTIMATE_PATH}`); expect(fetchMock.mock.calls[0][1]?.body).toEqual( JSON.stringify({ transaction_payload: bytesToHex(serializePayload(transaction.payload)), estimated_len: transactionByteLength, }) ); - expect(fetchMock.mock.calls[1][0]).toEqual(testnet.getTransactionFeeEstimateApiUrl()); + expect(fetchMock.mock.calls[1][0]).toEqual(`${HIRO_TESTNET_URL}${TRANSACTION_FEE_ESTIMATE_PATH}`); expect(fetchMock.mock.calls[1][1]?.body).toEqual( JSON.stringify({ transaction_payload: bytesToHex(serializePayload(transaction.payload)), @@ -1210,7 +1221,7 @@ test('Estimate transaction transfer fee', async () => { test('Estimate transaction fee fallback', async () => { const privateKey = 'cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01'; const poolAddress = 'ST11NJTTKGVT6D1HY4NJRVQWMQM7TVAR091EJ8P2Y'; - const network = new StacksTestnet({ url: 'http://localhost:3999' }); + const api = new StacksNodeApi({ url: 'http://localhost:3999' }); // http://localhost:3999/v2/fees/transaction fetchMock.once( @@ -1221,7 +1232,7 @@ test('Estimate transaction fee fallback', async () => { // http://localhost:3999/v2/fees/transfer fetchMock.once('1'); - const tx = await makeContractCall({ + const transaction = await makeContractCall({ senderKey: privateKey, contractAddress: 'ST000000000000000000002AMW42H', contractName: 'pox-2', @@ -1229,7 +1240,8 @@ test('Estimate transaction fee fallback', async () => { functionArgs: [uintCV(100_000), principalCV(poolAddress), noneCV(), noneCV()], anchorMode: AnchorMode.OnChainOnly, nonce: 1, - network, + network: STACKS_TESTNET, + api, }); // http://localhost:3999/v2/fees/transaction @@ -1241,8 +1253,8 @@ test('Estimate transaction fee fallback', async () => { // http://localhost:3999/v2/fees/transfer fetchMock.once('1'); - const testnet = new StacksTestnet(); - const resultEstimateFee = await estimateTransactionFeeWithFallback(tx, testnet); + const testnet = new StacksNodeApi({ network: STACKS_TESTNET }); + const resultEstimateFee = await estimateFee({ transaction, api: testnet }); expect(resultEstimateFee).toBe(201n); // http://localhost:3999/v2/fees/transaction @@ -1254,7 +1266,7 @@ test('Estimate transaction fee fallback', async () => { // http://localhost:3999/v2/fees/transfer fetchMock.once('2'); // double - const doubleRate = await estimateTransactionFeeWithFallback(tx, testnet); + const doubleRate = await estimateFee({ transaction, api: testnet }); expect(doubleRate).toBe(402n); expect(fetchMock.mock.calls.length).toEqual(6); @@ -1380,30 +1392,30 @@ test('Make STX token transfer with fetch account nonce', async () => { const amount = 12345; const fee = 0; const senderKey = 'cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01'; - const senderAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + const address = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; const memo = 'test memo'; - const network = new StacksTestnet(); - const apiUrl = network.getAccountApiUrl(senderAddress); fetchMock.mockOnce(`{"balance":"0", "nonce":${nonce}}`); - - const fetchNonce = await getNonce(senderAddress, network); + const fetchNonce = await getNonce({ + address, + api: new StacksNodeApi({ network: STACKS_TESTNET }), + }); fetchMock.mockOnce(`{"balance":"0", "nonce":${nonce}}`); - const transaction = await makeSTXTokenTransfer({ recipient, amount, senderKey, fee, memo, - network, anchorMode: AnchorMode.Any, + network: 'testnet', }); + const EXPECTED_ACCOUNT_URL = `${HIRO_TESTNET_URL}${ACCOUNT_PATH}/${address}?proof=0`; expect(fetchMock.mock.calls.length).toEqual(2); - expect(fetchMock.mock.calls[0][0]).toEqual(apiUrl); - expect(fetchMock.mock.calls[1][0]).toEqual(apiUrl); + expect(fetchMock.mock.calls[0][0]).toEqual(EXPECTED_ACCOUNT_URL); + expect(fetchMock.mock.calls[1][0]).toEqual(EXPECTED_ACCOUNT_URL); expect(fetchNonce.toString()).toEqual(nonce.toString()); expect(transaction.auth.spendingCondition?.nonce?.toString()).toEqual(nonce.toString()); }); @@ -1540,7 +1552,7 @@ test('Make sponsored STX token transfer with sponsor fee estimate', async () => const nonce = 2; const senderKey = 'edf9aee84d9b7abc145504dde6726c64f369d37ee34ded868fabd876c26570bc01'; const memo = 'test memo'; - const network = new StacksMainnet(); + const api = new StacksNodeApi(); const sponsorKey = '9888d734e6e80a943a6544159e31d6c7e342f695ec867d549c569fa0028892d401'; const sponsorNonce = 55; @@ -1554,7 +1566,7 @@ test('Make sponsored STX token transfer with sponsor fee estimate', async () => senderKey, fee, nonce, - memo: memo, + memo, sponsored: true, anchorMode: AnchorMode.Any, }); @@ -1596,7 +1608,7 @@ test('Make sponsored STX token transfer with sponsor fee estimate', async () => const sponsorSignedTx = await sponsorTransaction(sponsorOptions); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(network.getTransactionFeeEstimateApiUrl()); + expect(fetchMock.mock.calls[0][0]).toEqual(`${api.url}${TRANSACTION_FEE_ESTIMATE_PATH}`); const sponsorSignedTxSerialized = sponsorSignedTx.serialize(); @@ -1627,7 +1639,7 @@ test('Make sponsored STX token transfer with set tx fee', async () => { const nonce = 0; const senderKey = '8ca861519c4fa4a08de4beaa41688f60a24b575a976cf84099f38dc099a6d74401'; // const senderAddress = 'ST2HTEQF50SW4X8077F8RSR8WCT57QG166TVG0GCE'; - const network = new StacksTestnet(); + const api = new StacksNodeApi({ network: STACKS_TESTNET }); const sponsorKey = '9888d734e6e80a943a6544159e31d6c7e342f695ec867d549c569fa0028892d401'; // const sponsorAddress = 'ST2TPJ3NEZ63MMJ8AY9S45HZ10QSH51YF93GE89GQ'; @@ -1640,9 +1652,9 @@ test('Make sponsored STX token transfer with set tx fee', async () => { senderKey, fee, nonce, - network, sponsored: true, anchorMode: AnchorMode.Any, + api, }); const sponsorOptions = { @@ -1679,7 +1691,7 @@ test('Make sponsored contract deploy with sponsor fee estimate', async () => { const senderKey = '8ca861519c4fa4a08de4beaa41688f60a24b575a976cf84099f38dc099a6d74401'; const fee = 0; const nonce = 0; - const network = new StacksTestnet(); + const api = new StacksNodeApi({ network: STACKS_TESTNET }); const sponsorKey = '9888d734e6e80a943a6544159e31d6c7e342f695ec867d549c569fa0028892d401'; // const sponsorAddress = 'ST2TPJ3NEZ63MMJ8AY9S45HZ10QSH51YF93GE89GQ'; @@ -1695,9 +1707,9 @@ test('Make sponsored contract deploy with sponsor fee estimate', async () => { senderKey, fee, nonce, - network, sponsored: true, anchorMode: AnchorMode.Any, + api, }); const sponsorOptions = { @@ -1737,7 +1749,6 @@ test('Make sponsored contract call with sponsor nonce fetch', async () => { const buffer = bufferCV(utf8ToBytes('foo')); const senderKey = 'e494f188c2d35887531ba474c433b1e41fadd8eb824aca983447fd4bb8b277a801'; const nonce = 0; - const network = new StacksTestnet(); const fee = 0; const sponsorFee = 1000; @@ -1756,9 +1767,9 @@ test('Make sponsored contract call with sponsor nonce fetch', async () => { senderKey, fee, nonce, - network, sponsored: true, anchorMode: AnchorMode.Any, + network: 'testnet', }); const sponsorOptions = { @@ -1772,7 +1783,9 @@ test('Make sponsored contract call with sponsor nonce fetch', async () => { const sponsorSignedTx = await sponsorTransaction(sponsorOptions); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(network.getAccountApiUrl(sponsorAddress)); + expect(fetchMock.mock.calls[0][0]).toEqual( + `${HIRO_TESTNET_URL}${ACCOUNT_PATH}/${sponsorAddress}?proof=0` + ); const sponsorSignedTxSerialized = sponsorSignedTx.serialize(); @@ -1801,7 +1814,7 @@ test('Transaction broadcast success', async () => { const senderKey = 'edf9aee84d9b7abc145504dde6726c64f369d37ee34ded868fabd876c26570bc01'; const memo = 'test memo'; - const network = new StacksMainnet(); + const api = new StacksNodeApi(); const transaction = await makeSTXTokenTransfer({ recipient, @@ -1813,14 +1826,17 @@ test('Transaction broadcast success', async () => { anchorMode: AnchorMode.Any, }); - fetchMock.mockOnce('success'); + const txid = transaction.txid(); + fetchMock.mockOnce(`"${txid}"`); - const response: TxBroadcastResult = await broadcastTransaction(transaction, network); + const response: TxBroadcastResult = await broadcastTransaction({ transaction, api }); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(network.getBroadcastApiUrl()); - expect(fetchMock.mock.calls[0][1]?.body).toEqual(transaction.serialize()); - expect(response as TxBroadcastResultOk).toEqual({ txid: 'success' }); + expect(fetchMock.mock.calls[0][0]).toEqual(`${api.url}${BROADCAST_PATH}`); + expect(fetchMock.mock.calls[0][1]?.body).toEqual( + JSON.stringify({ tx: bytesToHex(transaction.serialize()) }) + ); + expect(response as TxBroadcastResultOk).toEqual({ txid }); }); test('Transaction broadcast success with string network name', async () => { @@ -1835,14 +1851,17 @@ test('Transaction broadcast success with string network name', async () => { anchorMode: AnchorMode.Any, }); - fetchMock.mockOnce('success'); + const txid = transaction.txid(); + fetchMock.mockOnce(`"${txid}"`); - const response: TxBroadcastResult = await broadcastTransaction(transaction, 'mainnet'); + const response: TxBroadcastResult = await broadcastTransaction({ transaction }); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(new StacksMainnet().getBroadcastApiUrl()); - expect(fetchMock.mock.calls[0][1]?.body).toEqual(transaction.serialize()); - expect(response as TxBroadcastResultOk).toEqual({ txid: 'success' }); + expect(fetchMock.mock.calls[0][0]).toEqual(`${HIRO_MAINNET_URL}${BROADCAST_PATH}`); + expect(fetchMock.mock.calls[0][1]?.body).toEqual( + JSON.stringify({ tx: bytesToHex(transaction.serialize()) }) + ); + expect(response as TxBroadcastResultOk).toEqual({ txid }); }); test('Transaction broadcast success with network detection', async () => { @@ -1857,14 +1876,17 @@ test('Transaction broadcast success with network detection', async () => { anchorMode: AnchorMode.Any, }); - fetchMock.mockOnce('success'); + const txid = transaction.txid(); + fetchMock.mockOnce(`"${txid}"`); - const response: TxBroadcastResult = await broadcastTransaction(transaction); + const response: TxBroadcastResult = await broadcastTransaction({ transaction }); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(new StacksTestnet().getBroadcastApiUrl()); - expect(fetchMock.mock.calls[0][1]?.body).toEqual(transaction.serialize()); - expect(response as TxBroadcastResultOk).toEqual({ txid: 'success' }); + expect(fetchMock.mock.calls[0][0]).toEqual(`${HIRO_TESTNET_URL}${BROADCAST_PATH}`); + expect(fetchMock.mock.calls[0][1]?.body).toEqual( + JSON.stringify({ tx: bytesToHex(transaction.serialize()) }) + ); + expect(response as TxBroadcastResultOk).toEqual({ txid }); }); test('Transaction broadcast with attachment', async () => { @@ -1874,9 +1896,7 @@ test('Transaction broadcast with attachment', async () => { const nonce = 0; const senderKey = 'edf9aee84d9b7abc145504dde6726c64f369d37ee34ded868fabd876c26570bc01'; const memo = 'test memo'; - const attachment = utf8ToBytes('this is an attachment...'); - - const network = new StacksMainnet(); + const attachment = bytesToHex(utf8ToBytes('this is an attachment...')); const transaction = await makeSTXTokenTransfer({ recipient, @@ -1888,19 +1908,20 @@ test('Transaction broadcast with attachment', async () => { anchorMode: AnchorMode.Any, }); - fetchMock.mockOnce('success'); + const txid = transaction.txid(); + fetchMock.mockOnce(`"${txid}"`); - const response: TxBroadcastResult = await broadcastTransaction(transaction, network, attachment); + const response: TxBroadcastResult = await broadcastTransaction({ transaction, attachment }); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(network.getBroadcastApiUrl()); + expect(fetchMock.mock.calls[0][0]).toEqual(`${HIRO_MAINNET_URL}${BROADCAST_PATH}`); expect(fetchMock.mock.calls[0][1]?.body).toEqual( JSON.stringify({ tx: bytesToHex(transaction.serialize()), - attachment: bytesToHex(attachment), + attachment, }) ); - expect(response as TxBroadcastResultOk).toEqual({ txid: 'success' }); + expect(response as TxBroadcastResultOk).toEqual({ txid }); }); test('Transaction broadcast returns error', async () => { @@ -1911,8 +1932,6 @@ test('Transaction broadcast returns error', async () => { const senderKey = 'edf9aee84d9b7abc145504dde6726c64f369d37ee34ded868fabd876c26570bc01'; const memo = 'test memo'; - const network = new StacksMainnet(); - const transaction = await makeSTXTokenTransfer({ recipient, amount, @@ -1937,9 +1956,9 @@ test('Transaction broadcast returns error', async () => { fetchMock.mockOnce(JSON.stringify(rejection), { status: 400 }); - const result = await broadcastTransaction(transaction, network); + const result = await broadcastTransaction({ transaction }); expect((result as TxBroadcastResultRejected).reason).toEqual(TxRejectedReason.BadNonce); - expect((result as TxBroadcastResultRejected).reason_data).toEqual(rejection.reason_data); + expect((result as BadNonceRejection).reason_data).toEqual(rejection.reason_data); }); test('Transaction broadcast fails', async () => { @@ -1950,8 +1969,6 @@ test('Transaction broadcast fails', async () => { const senderKey = 'edf9aee84d9b7abc145504dde6726c64f369d37ee34ded868fabd876c26570bc01'; const memo = 'test memo'; - const network = new StacksMainnet(); - const transaction = await makeSTXTokenTransfer({ recipient, amount, @@ -1964,7 +1981,7 @@ test('Transaction broadcast fails', async () => { fetchMock.mockOnce('test', { status: 400 }); - await expect(broadcastTransaction(transaction, network)).rejects.toThrow(); + await expect(broadcastTransaction({ transaction })).rejects.toThrow(); }); test('Make contract-call with network ABI validation', async () => { @@ -1976,8 +1993,6 @@ test('Make contract-call with network ABI validation', async () => { const fee = 0; - const network = new StacksTestnet(); - const abi = fs.readFileSync('./tests/abi/kv-store-abi.json').toString(); fetchMock.mockOnce(abi); @@ -1989,14 +2004,16 @@ test('Make contract-call with network ABI validation', async () => { functionArgs: [buffer], fee, nonce: 1, - network: new StacksTestnet(), + network: STACKS_TESTNET, validateWithAbi: true, postConditionMode: PostConditionMode.Allow, anchorMode: AnchorMode.Any, }); expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(network.getAbiApiUrl(contractAddress, contractName)); + expect(fetchMock.mock.calls[0][0]).toEqual( + `${HIRO_TESTNET_URL}${CONTRACT_ABI_PATH}/${contractAddress}/${contractName}` + ); }); test('Make contract-call with provided ABI validation', async () => { @@ -2033,8 +2050,6 @@ test('Make contract-call with network ABI validation failure', async () => { const fee = 0; - const network = new StacksTestnet(); - fetchMock.mockOnce('failed', { status: 404 }); let error; @@ -2056,7 +2071,7 @@ test('Make contract-call with network ABI validation failure', async () => { error = e; } - const abiUrl = network.getAbiApiUrl(contractAddress, contractName); + const abiUrl = `${HIRO_TESTNET_URL}${CONTRACT_ABI_PATH}/${contractAddress}/${contractName}`; expect(error).toEqual( new Error( `Error fetching contract ABI for contract "kv-store" at address ST3KC0MTNW34S1ZXD36JYKFD3JJMWA01M55DSJ4JE. Response 404: Not Found. Attempted to fetch ${abiUrl} and failed with the message: "failed"` @@ -2069,7 +2084,7 @@ test('Call read-only function', async () => { const contractName = 'kv-store'; const functionName = 'get-value?'; const buffer = bufferCVFromString('foo'); - const network = new StacksTestnet(); + const api = new StacksNodeApi({ network: STACKS_TESTNET }); const senderAddress = 'ST2F4BK4GZH6YFBNHYDDGN4T1RKBA7DA1BJZPJEJJ'; const mockResult = bufferCVFromString('test'); @@ -2078,11 +2093,11 @@ test('Call read-only function', async () => { contractName, functionName, functionArgs: [buffer], - network, senderAddress, + api, }; - const apiUrl = network.getReadOnlyFunctionCallApiUrl(contractAddress, contractName, functionName); + const apiUrl = `${api.url}${READONLY_FUNCTION_CALL_PATH}/${contractAddress}/kv-store/get-value%3F`; // uri encoded fetchMock.mockOnce(`{"okay": true, "result": "0x${bytesToHex(serializeCV(mockResult))}"}`); const result = await callReadOnlyFunction(options); @@ -2101,8 +2116,8 @@ test('Call read-only function with network string', async () => { contractName: 'kv-store', functionName: 'get-value?', functionArgs: [bufferCVFromString('foo')], - network: 'testnet', senderAddress: 'ST2F4BK4GZH6YFBNHYDDGN4T1RKBA7DA1BJZPJEJJ', + api: new StacksNodeApi({ network: 'testnet' }), }); expect(fetchMock.mock.calls.length).toEqual(1); diff --git a/packages/network/tests/fetch.test.ts b/packages/transactions/tests/fetch.test.ts similarity index 98% rename from packages/network/tests/fetch.test.ts rename to packages/transactions/tests/fetch.test.ts index f17d3ad02..178625f93 100644 --- a/packages/network/tests/fetch.test.ts +++ b/packages/transactions/tests/fetch.test.ts @@ -1,5 +1,5 @@ +import { fetchWrapper, getFetchOptions, setFetchOptions } from '@stacks/common'; import fetchMock from 'jest-fetch-mock'; -import { fetchWrapper, getFetchOptions, setFetchOptions } from '../src/fetch'; test('Verify fetch private options', async () => { const defaultOptioins = getFetchOptions(); diff --git a/packages/network/tests/fetchMiddleware.test.ts b/packages/transactions/tests/fetchMiddleware.test.ts similarity index 99% rename from packages/network/tests/fetchMiddleware.test.ts rename to packages/transactions/tests/fetchMiddleware.test.ts index 8d7dce46f..d7a80e919 100644 --- a/packages/network/tests/fetchMiddleware.test.ts +++ b/packages/transactions/tests/fetchMiddleware.test.ts @@ -1,11 +1,11 @@ -import fetchMock from 'jest-fetch-mock'; import { - createApiKeyMiddleware, - createFetchFn, FetchMiddleware, RequestContext, ResponseContext, -} from '../src/fetch'; + createApiKeyMiddleware, + createFetchFn, +} from '@stacks/common'; +import fetchMock from 'jest-fetch-mock'; beforeEach(() => { fetchMock.resetMocks(); diff --git a/packages/transactions/tests/fetchUtil.test.ts b/packages/transactions/tests/fetchUtil.test.ts index d2702be1c..cd1228fce 100644 --- a/packages/transactions/tests/fetchUtil.test.ts +++ b/packages/transactions/tests/fetchUtil.test.ts @@ -1,13 +1,15 @@ -import { createApiKeyMiddleware, createFetchFn, StacksTestnet } from '@stacks/network'; import fetchMock from 'jest-fetch-mock'; -import { broadcastTransaction, makeSTXTokenTransfer } from '../src/builders'; +import { broadcastTransaction } from '../src'; +import { StacksNodeApi } from '../src/api'; +import { makeSTXTokenTransfer } from '../src/builders'; import { AnchorMode } from '../src/constants'; +import { createApiKeyMiddleware, createFetchFn } from '@stacks/common'; test('fetchFn is used in network requests', async () => { const apiKey = 'MY_KEY'; const middleware = createApiKeyMiddleware({ apiKey }); const fetchFn = createFetchFn(middleware); - const network = new StacksTestnet({ fetchFn }); + const api = new StacksNodeApi({ fetch: fetchFn }); const transaction = await makeSTXTokenTransfer({ recipient: 'SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159', @@ -19,8 +21,10 @@ test('fetchFn is used in network requests', async () => { anchorMode: AnchorMode.Any, }); - fetchMock.mockOnce('success'); - await broadcastTransaction(transaction, network); + const txid = transaction.txid(); + fetchMock.mockOnce(`"${txid}"`); + + await broadcastTransaction({ transaction, api }); expect((fetchMock.mock.calls[0][1]?.headers as Headers)?.get('x-api-key')).toContain(apiKey); }); diff --git a/packages/transactions/tests/postcondition.test.ts b/packages/transactions/tests/postcondition.test.ts index 73e1b06aa..45354c077 100644 --- a/packages/transactions/tests/postcondition.test.ts +++ b/packages/transactions/tests/postcondition.test.ts @@ -20,7 +20,7 @@ import { FungibleConditionCode, NonFungibleConditionCode, StacksMessageType, - PostConditionPrincipalID, + PostConditionPrincipalId, } from '../src/constants'; import { serializeDeserialize } from './macros'; @@ -44,7 +44,7 @@ test('STX post condition serialization and deserialization', () => { StacksMessageType.PostCondition ) as STXPostCondition; expect(deserialized.conditionType).toBe(postConditionType); - expect(deserialized.principal.prefix).toBe(PostConditionPrincipalID.Standard); + expect(deserialized.principal.prefix).toBe(PostConditionPrincipalId.Standard); expect(addressToString(deserialized.principal.address)).toBe(address); expect(deserialized.conditionCode).toBe(conditionCode); expect(deserialized.amount.toString()).toBe(amount.toString()); @@ -71,7 +71,7 @@ test('Fungible post condition serialization and deserialization', () => { StacksMessageType.PostCondition ) as FungiblePostCondition; expect(deserialized.conditionType).toBe(postConditionType); - expect(deserialized.principal.prefix).toBe(PostConditionPrincipalID.Standard); + expect(deserialized.principal.prefix).toBe(PostConditionPrincipalId.Standard); expect(addressToString(deserialized.principal.address)).toBe(address); expect(deserialized.conditionCode).toBe(conditionCode); expect(deserialized.amount.toString()).toBe(amount.toString()); @@ -108,7 +108,7 @@ test('Non-fungible post condition serialization and deserialization', () => { StacksMessageType.PostCondition ) as NonFungiblePostCondition; expect(deserialized.conditionType).toBe(postConditionType); - expect(deserialized.principal.prefix).toBe(PostConditionPrincipalID.Contract); + expect(deserialized.principal.prefix).toBe(PostConditionPrincipalId.Contract); expect(addressToString(deserialized.principal.address)).toBe(address); expect((deserialized.principal as ContractPrincipal).contractName.content).toBe(contractName); expect(deserialized.conditionCode).toBe(conditionCode); @@ -144,7 +144,7 @@ test('Non-fungible post condition with string IDs serialization and deserializat StacksMessageType.PostCondition ) as NonFungiblePostCondition; expect(deserialized.conditionType).toBe(postConditionType); - expect(deserialized.principal.prefix).toBe(PostConditionPrincipalID.Contract); + expect(deserialized.principal.prefix).toBe(PostConditionPrincipalId.Contract); expect(addressToString(deserialized.principal.address)).toBe(address); expect((deserialized.principal as ContractPrincipal).contractName.content).toBe(contractName); expect(deserialized.conditionCode).toBe(conditionCode); diff --git a/packages/transactions/tests/utils.test.ts b/packages/transactions/tests/utils.test.ts index 830766a0c..8fbe7c408 100644 --- a/packages/transactions/tests/utils.test.ts +++ b/packages/transactions/tests/utils.test.ts @@ -1,11 +1,4 @@ -import { validateStacksAddress, validateTxId } from '../src/utils'; - -const TX_ID_WITH_NO_0x = '117a6522b4e9ec27ff10bbe3940a4a07fd58e5352010b4143992edb05a7130c7'; -const TX_ID = '0x117a6522b4e9ec27ff10bbe3940a4a07fd58e5352010b4143992edb05a7130c7'; -const INVALID_EXAMPLE = - 'Failed to deserialize posted transaction: Invalid Stacks string: non-printable or non-ASCII string'; - -const INVALID_EXAMPLE_WITH_TXID = `Failed to deserialize posted transaction: Invalid Stacks string: non-printable or non-ASCII string. ${TX_ID}`; +import { validateStacksAddress } from '../src/utils'; describe(validateStacksAddress.name, () => { test('it returns true for a legit address', () => { @@ -32,24 +25,3 @@ describe(validateStacksAddress.name, () => { ); }); }); - -describe(validateTxId.name, () => { - test('correctly validates a txid without 0x', () => { - expect(validateTxId(TX_ID_WITH_NO_0x)).toEqual(true); - }); - test('correctly validates a txid with 0x', () => { - expect(validateTxId(TX_ID)).toEqual(true); - }); - test('errors when it is too short', () => { - expect(validateTxId(TX_ID.split('30c7')[0])).toEqual(false); - }); - test('errors when it is too long', () => { - expect(validateTxId(TX_ID + TX_ID)).toEqual(false); - }); - test('errors when a message is passed', () => { - expect(validateTxId(INVALID_EXAMPLE)).toEqual(false); - }); - test('errors when a message is passed even though there is a valid txid included', () => { - expect(validateTxId(INVALID_EXAMPLE_WITH_TXID)).toEqual(false); - }); -}); diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index 9c220c8c1..375875fd7 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -33,7 +33,7 @@ "@stacks/auth": "^6.9.0", "@stacks/common": "^6.8.1", "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "@stacks/api": "^6.8.1", "@stacks/profile": "^6.9.0", "@stacks/storage": "^6.9.0", "@stacks/transactions": "^6.9.0", diff --git a/packages/wallet-sdk/src/utils.ts b/packages/wallet-sdk/src/utils.ts index b49d76c53..b921e0e9f 100644 --- a/packages/wallet-sdk/src/utils.ts +++ b/packages/wallet-sdk/src/utils.ts @@ -1,7 +1,7 @@ -import { bytesToHex, ChainID } from '@stacks/common'; +import { bytesToHex } from '@stacks/common'; import { getPublicKeyFromPrivate, publicKeyToBtcAddress, randomBytes } from '@stacks/encryption'; -import { createFetchFn, FetchFn } from '@stacks/network'; import { GaiaHubConfig } from '@stacks/storage'; +import { ChainId, FetchFn, createFetchFn } from '@stacks/transactions'; import { Json, TokenSigner } from 'jsontokens'; import { parseZoneFile } from 'zone-file'; @@ -125,9 +125,9 @@ export const makeGaiaAssociationToken = ({ }; interface WhenChainIdMap { - [ChainID.Mainnet]: T; - [ChainID.Testnet]: T; + [ChainId.Mainnet]: T; + [ChainId.Testnet]: T; } -export function whenChainId(chainId: ChainID) { +export function whenChainId(chainId: ChainId) { return (chainIdMap: WhenChainIdMap): T => chainIdMap[chainId]; } diff --git a/packages/wallet-sdk/tests/models/wallet.test.ts b/packages/wallet-sdk/tests/models/wallet.test.ts index 152e2910a..6c6313495 100644 --- a/packages/wallet-sdk/tests/models/wallet.test.ts +++ b/packages/wallet-sdk/tests/models/wallet.test.ts @@ -1,8 +1,8 @@ -import { StacksMainnet } from '@stacks/network'; import fetchMock from 'jest-fetch-mock'; import { generateWallet, restoreWalletAccounts } from '../../src'; import { mockGaiaHubInfo } from '../mocks'; +import { STACKS_MAINNET } from '@stacks/transactions/src'; const SECRET_KEY = 'sound idle panel often situate develop unit text design antenna ' + @@ -27,7 +27,7 @@ test('restore wallet with username not owned by stx private key', async () => { const wallet = await restoreWalletAccounts({ wallet: baseWallet, gaiaHubUrl: 'https://hub.gaia.com', - network: new StacksMainnet(), + network: STACKS_MAINNET, }); expect(wallet?.accounts[0]?.username).toEqual(undefined); @@ -43,20 +43,24 @@ test('restore wallet with username owned by stx private key', async () => { fetchMock .once(mockGaiaHubInfo) .once(JSON.stringify('no found'), { status: 404 }) // TODO mock fetch legacy wallet config - .once(JSON.stringify({ // mock wallet-config.json - iv: "a39461ec18e5dea8ac759d5b8141319d", - ephemeralPK: "03e04d0046a9dd5b7a8fea4de55a8d912909738b26f2c72f8e5962217fa45f00bb", - cipherText: "bf16d2da29b54a153d553ab99597096f3fa11bd964441927355c1d979bf614477c8ceba7098620a37fa98f92f0f79813f771b6e2c2087e6fd2b1675d98a5e14f28cba28134dac2913bcb06f469439a16b47778747c7e93f50169727a7b9053b5441c8645fc729b28f063d2ffd673a01342e2cbc4fbf0e05350a67ec53ee3b7e43ff4e2dddbded5868cc3f5c4ca323b621bd13b5f9f036dc4406c2418e98b1b905974479cc79ab102d9ba1eb7fe858987dd0777ed3b0356d6bd0bc775213ef878bffaa58c40365d831a9e436fbfc388bcff2909659cab38a65ae8512508f6fda247437d4819c98ea15e48a4a00c1594eb58f7bf7eb85aad7cd51e5b43a7ca1fec06385be125d0b8c07bac1ac1094bb4687e620f23a5a14f4b20674ccd198271eb2451f12ad294efa79a9b5a001a3682a5ec833140b78333f57ce9912f60ff94edf99ee0b5e59ddfe7fb4a1472c0d303eeab22471585a5a5689ed9779e4ded10a4feecf5107df4c847522b2d6c95ed1e45cccb8b834b47a79f6671b49ffbdb02e4887465ca521472b7f11a53be0221eaeeffed2c6cf4d17a6fdae4b8f2b963d8a102c5376e6fa01bdaf9dc3d544c9954090b23fc02c8f500319b0cc43d7f73ff5012514d473afc818967eb0d0837a9c6920f9bc39e5f49fefdbc4fa33e6be88820a1abaeacb836bd398e7031d4286121383f53e2873ea1c2f0b649a12aec7db049505c58323fb34aaaf8d59fc0b962df05b8e9ea0dabf7fc9923b25af9ff3bd08a0b2dea7462a9e889485aba8605592308847468e843fca721a70aae9528d4abaae1a539f57c624f06b5b7dfdcf0a9d94b509697a1d0020b5f0b60ab19cc6abaf14928612663a9b6f15e18174a0a31fc506c428df13889fd877b7b639106d72c1b9dc8509758035337776c9d2d489da61f8de92a880b8ab802bd098fea111ab6af59fadd275285c59a98f824d5023990664856f971a6928869d3447f4426cb6855be55e43778f65a77e4d4348da0eda3e45e56a5d8fd11aff6ec62f53eac1cd862", - mac: "623331e6804bf8c0dee7db7894a84ed02c70bf1ec0b6a5b2ccaff7d0638a9d88", - wasString: true - })) + .once( + JSON.stringify({ + // mock wallet-config.json + iv: 'a39461ec18e5dea8ac759d5b8141319d', + ephemeralPK: '03e04d0046a9dd5b7a8fea4de55a8d912909738b26f2c72f8e5962217fa45f00bb', + cipherText: + 'bf16d2da29b54a153d553ab99597096f3fa11bd964441927355c1d979bf614477c8ceba7098620a37fa98f92f0f79813f771b6e2c2087e6fd2b1675d98a5e14f28cba28134dac2913bcb06f469439a16b47778747c7e93f50169727a7b9053b5441c8645fc729b28f063d2ffd673a01342e2cbc4fbf0e05350a67ec53ee3b7e43ff4e2dddbded5868cc3f5c4ca323b621bd13b5f9f036dc4406c2418e98b1b905974479cc79ab102d9ba1eb7fe858987dd0777ed3b0356d6bd0bc775213ef878bffaa58c40365d831a9e436fbfc388bcff2909659cab38a65ae8512508f6fda247437d4819c98ea15e48a4a00c1594eb58f7bf7eb85aad7cd51e5b43a7ca1fec06385be125d0b8c07bac1ac1094bb4687e620f23a5a14f4b20674ccd198271eb2451f12ad294efa79a9b5a001a3682a5ec833140b78333f57ce9912f60ff94edf99ee0b5e59ddfe7fb4a1472c0d303eeab22471585a5a5689ed9779e4ded10a4feecf5107df4c847522b2d6c95ed1e45cccb8b834b47a79f6671b49ffbdb02e4887465ca521472b7f11a53be0221eaeeffed2c6cf4d17a6fdae4b8f2b963d8a102c5376e6fa01bdaf9dc3d544c9954090b23fc02c8f500319b0cc43d7f73ff5012514d473afc818967eb0d0837a9c6920f9bc39e5f49fefdbc4fa33e6be88820a1abaeacb836bd398e7031d4286121383f53e2873ea1c2f0b649a12aec7db049505c58323fb34aaaf8d59fc0b962df05b8e9ea0dabf7fc9923b25af9ff3bd08a0b2dea7462a9e889485aba8605592308847468e843fca721a70aae9528d4abaae1a539f57c624f06b5b7dfdcf0a9d94b509697a1d0020b5f0b60ab19cc6abaf14928612663a9b6f15e18174a0a31fc506c428df13889fd877b7b639106d72c1b9dc8509758035337776c9d2d489da61f8de92a880b8ab802bd098fea111ab6af59fadd275285c59a98f824d5023990664856f971a6928869d3447f4426cb6855be55e43778f65a77e4d4348da0eda3e45e56a5d8fd11aff6ec62f53eac1cd862', + mac: '623331e6804bf8c0dee7db7894a84ed02c70bf1ec0b6a5b2ccaff7d0638a9d88', + wasString: true, + }) + ) .once(JSON.stringify({ names: ['public_profile_for_testing.id.blockstack'] })) .once(JSON.stringify('ok')); // updateWalletConfig const wallet = await restoreWalletAccounts({ wallet: baseWallet, gaiaHubUrl: 'https://hub.gaia.com', - network: new StacksMainnet(), + network: STACKS_MAINNET, }); expect(wallet?.accounts[0]?.username).toEqual('public_profile_for_testing.id.blockstack'); diff --git a/tsconfig.json b/tsconfig.json index 14cc61235..d8daae2f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,13 @@ "compilerOptions": { "baseUrl": ".", "paths": { + "@stacks/api": ["packages/api/src"], "@stacks/auth": ["packages/auth/src"], "@stacks/bns": ["packages/bns/src"], "@stacks/cli": ["packages/cli/src"], "@stacks/common": ["packages/common/src"], "@stacks/encryption": ["packages/encryption/src"], "@stacks/keychain": ["packages/keychain/src"], - "@stacks/network": ["packages/network/src"], "@stacks/profile": ["packages/profile/src"], "@stacks/stacking": ["packages/stacking/src"], "@stacks/storage": ["packages/storage/src"], @@ -23,9 +23,9 @@ "entryPointStrategy": "packages", "entryPoints": [ // "packages/*", without packages/cli + "packages/api", "packages/auth", "packages/encryption", - "packages/network", "packages/stacking", "packages/transactions", "packages/bns",