├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── launch.json ├── README.md ├── SUMMARY.md ├── adding-metadata.md ├── bun.lockb ├── package.json ├── reinscription.md ├── src ├── burnOrdinals.ts ├── cancelListings.test.ts ├── cancelListings.ts ├── constants.ts ├── createListings.test.ts ├── createListings.ts ├── createOrdinals.test.ts ├── createOrdinals.ts ├── deployBsv21.test.ts ├── deployBsv21.ts ├── index.test.ts ├── index.ts ├── purchaseOrdListing.test.ts ├── purchaseOrdListing.ts ├── sendOrdinals.ts ├── sendUtxos.test.ts ├── sendUtxos.ts ├── signData.ts ├── templates │ ├── ordLock.ts │ └── ordP2pkh.ts ├── testdata │ ├── invalid_400x300.png │ └── valid_300x300.png ├── transferOrdinals.test.ts ├── transferOrdinals.ts ├── types.ts ├── utils │ ├── broadcast.ts │ ├── fetch.ts │ ├── httpClient.ts │ ├── icon.ts │ ├── paymail.ts │ ├── strings.ts │ ├── subtypeData.ts │ ├── utxo.test.ts │ └── utxo.ts └── validate.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | yarn-error.log 2 | node_modules 3 | .env 4 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | src/ 3 | *.test.ts 4 | node_modules/ 5 | jest.config.ts -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { "recommendations": ["biomejs.biome"] } 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}\\dist\\index.cjs", 13 | "outFiles": ["${workspaceFolder}/**/*.js"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: js-1sat-ord 3 | --- 4 | 5 | # 1Sat Ordinals - JS Library 6 | 7 | A Javascript library for creating and managing 1Sat Ordinal inscriptions and transactions. Uses `@bsv/sdk` under the hood. 8 | 9 | It provides functions for listing, cancelling and purchasing Ordinal Lock transactions. 10 | 11 | It also privides helpers for fetching utxos for payments, nfts, and tokens. 12 | 13 | ### Install 14 | 15 | Install the library, and it's peer dependency. We recommend using Bun for the best performance, but you can also use Yarn or npm: 16 | 17 | ```bash 18 | # Using Bun (recommended) 19 | bun add js-1sat-ord @bsv/sdk 20 | 21 | # Using Yarn 22 | yarn add js-1sat-ord @bsv/sdk 23 | 24 | # Using npm 25 | npm i js-1sat-ord @bsv/sdk 26 | ``` 27 | 28 | ### Usage 29 | 30 | ```ts 31 | import { createOrdinals, sendOrdinals, sendUtxos, deployBsv21Token, transferOrdToken } from 'js-1sat-ord' 32 | ``` 33 | 34 | ### Example 35 | 36 | Prepare some utxos to use in the following format. Be sure to use base64 encoded scripts. We use this encoding because it makes large scripts smaller in size. 37 | 38 | ```ts 39 | import type { Utxo } from "js-1sat-ord"; 40 | 41 | const utxo: Utxo = { 42 | satoshis: 269114, 43 | txid: "61fd6e240610a9e9e071c34fc87569ef871760ea1492fe1225d668de4d76407e", 44 | script: "", 45 | vout: 1, 46 | }; 47 | ``` 48 | 49 | You can use the helper `fetchPayUtxos(address)` to fetch unspent transaction outputs from the public 1Sat API and create the scripts with the correct encoding (base64). This should be a BSV address, not your ordinals address. Note: By default the script encoding will be base64, but you can provide a 2nd parameter and specify hex or asm encoding for the script property. 50 | 51 | Note: `Utxo` and `NftUtxo` and `TokenUtxo` have an optional `pk` field for specifying a Private Key for unlocking this utxo. This is helpful when multiple keys own the inputs and you need to spend them in a single traqnsaction. 52 | 53 | ```ts 54 | import { fetchPayUtxos } from "js-1sat-ord"; 55 | 56 | const utxos = await fetchPayUtxos(payAddress) 57 | ``` 58 | 59 | For NFTUtxos: 60 | 61 | ```ts 62 | import { fetchNftUtxos } from "js-1sat-ord" 63 | 64 | // collectionId is optional 65 | const collectionId = "1611d956f397caa80b56bc148b4bce87b54f39b234aeca4668b4d5a7785eb9fa_0" 66 | const nftUtxos = await fetchNftUtxos(ordAddress, collectionId) 67 | ``` 68 | 69 | For Token Utxos: 70 | 71 | ```ts 72 | import { fetchTokenUtxos, type TokenType } from "js-1sat-ord" 73 | 74 | const protocol = TokenType.BSV21; 75 | const tokenId = "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0"; 76 | const tokenUtxos = await fetchTokenUtxos(protocol, tokenId, ordAddress); 77 | ``` 78 | 79 | #### Prepare Inscription 80 | 81 | For a markdown inscription, you can create a string and convert it to base64: 82 | 83 | ```ts 84 | import type { Inscription } from "js-1sat-ord"; 85 | 86 | 87 | // Create a markdown string 88 | const markdownContent = "# Hello World!\n\nThis is a 1Sat Ordinal inscription."; 89 | 90 | // Convert to base64 91 | const encodedFileData = Buffer.from(markdownContent).toString('base64'); 92 | 93 | // Prepare the inscription object 94 | const inscription: Inscription = { 95 | dataB64: encodedFileData, 96 | contentType: "text/markdown" 97 | }; 98 | ``` 99 | 100 | #### Prepare Keys 101 | 102 | Be sure to use different keys for ordinals and normal payments: 103 | 104 | ```ts 105 | import { PrivateKey } from "js-1sat-ord"; 106 | 107 | const paymentPk = PrivateKey.fromWif(paymentWif); 108 | const ordPk = PrivateKey.fromWif(ordWif); 109 | ``` 110 | 111 | ### Create Ordinals 112 | 113 | The `createOrdinals` function creates a transaction with inscription outputs: 114 | 115 | ```ts 116 | import type { CreateOrdinalsConfig } from "js-1sat-ord"; 117 | 118 | const config: CreateOrdinalsConfig = { 119 | utxos: [utxo], 120 | destinations: [{ 121 | address: ordinalDestinationAddress, 122 | inscription: { dataB64: encodedFileData, contentType: "text/markdown" } 123 | }], 124 | paymentPk: paymentPk 125 | }; 126 | 127 | const result = await createOrdinals(config); 128 | ``` 129 | 130 | ### Send Ordinals 131 | 132 | Sends ordinals to the given destinations: 133 | 134 | ```ts 135 | import type { SendOrdinalsConfig } from "js-1sat-ord"; 136 | 137 | const config: SendOrdinalsConfig = { 138 | paymentUtxos: [paymentUtxo], 139 | ordinals: [ordinalUtxo], 140 | paymentPk: paymentPk, 141 | ordPk: ordPk, 142 | destinations: [{ 143 | address: destinationAddress, 144 | inscription: { dataB64: encodedFileData, contentType: "text/markdown" } 145 | }] 146 | }; 147 | 148 | const result = await sendOrdinals(config); 149 | ``` 150 | 151 | ### Deploy a BSV21 Token 152 | 153 | ```ts 154 | import type { DeployBsv21TokenConfig } from "js-1sat-ord"; 155 | 156 | const config: DeployBsv21TokenConfig = { 157 | symbol: "MYTICKER", 158 | icon: "", 159 | utxos: [utxo], 160 | initialDistribution: { address: destinationAddress, tokens: 10 }, 161 | paymentPk: paymentPk, 162 | destinationAddress: destinationAddress 163 | }; 164 | 165 | const result = await deployBsv21Token(config); 166 | ``` 167 | 168 | ### Transfer BSV21 Tokens 169 | 170 | ```ts 171 | import type { TransferBsv21TokenConfig } from "js-1sat-ord"; 172 | 173 | const config: TransferBsv21TokenConfig = { 174 | protocol: TokenType.BSV21, 175 | tokenID: tokenID, 176 | utxos: [utxo], 177 | inputTokens: [tokenUtxo], 178 | distributions: [{ address: destinationAddress, tokens: 0.1 }], 179 | paymentPk: paymentPk, 180 | ordPk: ordPk 181 | }; 182 | 183 | const result = await transferOrdToken(config); 184 | ``` 185 | 186 | Note: To burn tokens you can set the optional `burn` parameter to `true` 187 | Note: You can use the optional `splitConfig` parameter to configure how and when to split token change outputs, and whether change outputs should include metadata. 188 | Note: You can use the optional `tokenInputMode` parameter to configure whether `all` tokens are consumed, or only what's `needed`. Default is `needed`. 189 | 190 | ### Send Utxos 191 | 192 | Sends utxos to the given destination: 193 | 194 | ```ts 195 | import type { SendUtxosConfig } from "js-1sat-ord"; 196 | 197 | const config: SendUtxosConfig = { 198 | utxos: [utxo], 199 | paymentPk: paymentPk, 200 | payments: [{ to: destinationAddress, amount: 1000 }] 201 | }; 202 | 203 | const { tx } = await sendUtxos(config); 204 | ``` 205 | 206 | ### Create Ordinal Listings 207 | Creates a listing using an "Ordinal Lock" script. Can be purchased by anyone by sending a specific amount to the provided address. 208 | 209 | ```ts 210 | const listings = [{ 211 | payAddress: addressToReceivePayment; 212 | price: 100000; // price in satoshis 213 | listingUtxo, 214 | ordAddress: returnAddressForCancel; 215 | }] 216 | 217 | const config: CreateOrdListingsConfig = { 218 | utxos: [utxo], 219 | listings, 220 | paymentPk, 221 | ordPk, 222 | } 223 | 224 | const { tx } = await createOrdListings(config); 225 | ``` 226 | 227 | ### Purchase Ordinal Listing 228 | 229 | ```ts 230 | const config: PurchaseOrdListingConfig ={ 231 | utxos: [utxo], 232 | paymentPk, 233 | listingUtxo, 234 | ordAddress, 235 | }; 236 | 237 | const { tx } = await purchaseOrdListing(config); 238 | ``` 239 | 240 | ### Cancel Ordinal Listings 241 | Spends the ordinal lock without payment, returning the ordinal to the address specified in the listing contract. 242 | 243 | ```ts 244 | const config: CancelOrdListingsConfig = { utxos, listingUtxos, ordPk, paymentPk }; 245 | const { tx } = await cancelOrdListings(config); 246 | ``` 247 | 248 | ### Additional Configuration Options 249 | 250 | Each function accepts additional configuration options not shown in the examples above. These may include: 251 | 252 | - `changeAddress`: Address to send change to (if not provided, defaults to the payment key's address) 253 | - `satsPerKb`: Satoshis per kilobyte for fee calculation 254 | - `metaData`: MAP (Magic Attribute Protocol) metadata to include in inscriptions 255 | - `signer`: Custom signer object for transaction signing 256 | - `additionalPayments`: Additional payments to include in the transaction 257 | 258 | Refer to the function documentation for a complete list of configuration options for each function. 259 | 260 | ### Broadcasting 261 | 262 | ```ts 263 | import { oneSatBroadcaster } from "js-1sat-ord" 264 | 265 | // ... 266 | 267 | const { status, txid, message } = await tx.broadcast(oneSatBroadcaster()) 268 | ``` 269 | 270 | #### Using with Bundlers 271 | 272 | Since this package depends on `@bsv/sdk` there should be no issue with bundlers. 273 | 274 | ## Resources 275 | There is a public 1Sat API which is documented here: 276 | 277 | [https://ordinals.gorillapool.io/api/docs](https://ordinals.gorillapool.io/api/docs) 278 | 279 | --- -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | * [1Sat Ordinals - JS Library](README.md) 4 | * [Adding Metadata](adding-metadata.md) 5 | * [Reinscription](reinscription.md) 6 | -------------------------------------------------------------------------------- /adding-metadata.md: -------------------------------------------------------------------------------- 1 | # Adding Metadata 2 | 3 | You can optionally pass metadata. In this example we add the standard MAP keys `app` and `type` along with a geotag context with `geohash` and `context` fields to tag an inscription at a specific location. 4 | 5 | ```ts 6 | 7 | // set fee rate 8 | const satPerByteFee = 0.05 9 | 10 | // inscription 11 | const inscription = { dataB64: fireShard, contentType: "model/gltf-binary" } 12 | 13 | // Define MAP keys as a JSON object 14 | const metaData = { app: "ord-demo", type: "ord", context: "geohash", geohash: "dree547h7" } 15 | 16 | const tx = createOrdinal(utxo, ordinalDestinationAddress, paymentPk, changeAddress, satPerByteFee, inscription, metaData); 17 | ``` 18 | 19 | `app` - is publicly shown in the tx. Should be the app or platform name making the inscription. 20 | 21 | `context` = is a standard field making the tags apply to a particular type of identifier, in this case a `geohash`. 22 | 23 | `geohash` - is a standard geohash string referring to a location. 24 | 25 | both `createOrdinial` and `sendOrdinal` can optionally take metadata. 26 | 27 | ### 28 | 29 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BitcoinSchema/js-1sat-ord/04f48a39ae2e22767568c45e8e964da925bbc1c2/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-1sat-ord", 3 | "version": "0.1.82", 4 | "description": "1Sat Ordinals library", 5 | "types": "dist/index.d.ts", 6 | "type": "module", 7 | "main": "dist/index.cjs", 8 | "module": "dist/index.module.js", 9 | "unpkg": "dist/index.umd.js", 10 | "source": "src/index.ts", 11 | "exports": { 12 | ".": { 13 | "require": "./dist/index.cjs", 14 | "types": "./dist/index.d.ts", 15 | "default": "./dist/index.modern.js" 16 | } 17 | }, 18 | "files": [ 19 | "/dist" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/BitcoinSchema/js-1sat-ord.git" 24 | }, 25 | "scripts": { 26 | "build": "bun run clean && microbundle --globals @bsv/sdk=bsv", 27 | "clean": "rimraf -rf dist", 28 | "test": "bun test", 29 | "prepublishOnly": "bun run build", 30 | "fmt": "echo $(which biome) && biome format --write ." 31 | }, 32 | "keywords": [], 33 | "author": "Luke Rohenaz", 34 | "license": "MIT", 35 | "dependencies": { 36 | "image-meta": "^0.2.1", 37 | "satoshi-token": "^0.0.4", 38 | "sigma-protocol": "^0.1.6" 39 | }, 40 | "peerDependencies": { 41 | "@bsv/sdk": "^1.1.23" 42 | }, 43 | "devDependencies": { 44 | "@types/bun": "^1.1.16", 45 | "microbundle": "^0.15.1", 46 | "rimraf": "^6.0.1", 47 | "typescript": "^5.7.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /reinscription.md: -------------------------------------------------------------------------------- 1 | # Reinscription 2 | 3 | You can technically re-inscribe on the same Satoshi. Its up to the apps / indexers to determine what this means unless a standard approach emerges. 4 | 5 | ```ts 6 | // optional reinscription 7 | const reinscription = { dataB64: frostShard, contentType: "model/gltf-binary" } 8 | 9 | const tx = sendOrdinal( 10 | utxo, 11 | ordinal, 12 | paymentPk, 13 | changeAddress, 14 | satPerByteFee, 15 | ordPk, 16 | ordDestinationAddress, 17 | reinscription 18 | ); 19 | ``` 20 | -------------------------------------------------------------------------------- /src/burnOrdinals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Transaction, 3 | SatoshisPerKilobyte, 4 | Script, 5 | Utils, 6 | PrivateKey, 7 | } from "@bsv/sdk"; 8 | import { DEFAULT_SAT_PER_KB, MAP_PREFIX } from "./constants"; 9 | import OrdP2PKH from "./templates/ordP2pkh"; 10 | import type { 11 | BaseResult, 12 | BurnOrdinalsConfig, 13 | } from "./types"; 14 | import { inputFromB64Utxo } from "./utils/utxo"; 15 | import { toHex } from "./utils/strings"; 16 | 17 | /** 18 | * Burn ordinals by consuming them as fees 19 | * @param {BurnOrdinalsConfig} config - Configuration object for sending ordinals 20 | * @param {PrivateKey} config.ordPk - Private key to sign ordinals 21 | * @param {Utxo} config.ordinals - 1Sat Ordinal Utxos to spend (with base64 encoded scripts) 22 | * @param {BurnMAP} [config.metaData] - Optional. MAP (Magic Attribute Protocol) metadata to include in an unspendable output OP_FALSE OP_RETURN 23 | * @returns {Promise} Transaction, spent outpoints 24 | */ 25 | export const burnOrdinals = async ( 26 | config: BurnOrdinalsConfig, 27 | ): Promise => { 28 | const tx = new Transaction(); 29 | const spentOutpoints: string[] = []; 30 | const { ordinals, metaData, ordPk } = config; 31 | 32 | // Inputs 33 | // Add ordinal inputs 34 | for (const ordUtxo of ordinals) { 35 | if (ordUtxo.satoshis !== 1) { 36 | throw new Error("1Sat Ordinal utxos must have exactly 1 satoshi"); 37 | } 38 | const ordKeyToUse = ordUtxo.pk || ordPk; 39 | if(!ordKeyToUse) { 40 | throw new Error("Private key is required to sign the ordinal"); 41 | } 42 | 43 | const input = inputFromB64Utxo( 44 | ordUtxo, 45 | new OrdP2PKH().unlock( 46 | ordKeyToUse, 47 | "all", 48 | true, 49 | ordUtxo.satoshis, 50 | Script.fromBinary(Utils.toArray(ordUtxo.script, "base64")), 51 | ), 52 | ); 53 | spentOutpoints.push(`${ordUtxo.txid}_${ordUtxo.vout}`); 54 | tx.addInput(input); 55 | } 56 | 57 | // Outputs 58 | // Add metadata output 59 | 60 | // MAP.app and MAP.type keys are required 61 | if (metaData && (!metaData.app || !metaData.type)) { 62 | throw new Error("MAP.app and MAP.type are required fields"); 63 | } 64 | 65 | let metaAsm = ""; 66 | 67 | if (metaData?.app && metaData?.type) { 68 | const mapPrefixHex = toHex(MAP_PREFIX); 69 | const mapCmdValue = toHex("SET"); 70 | metaAsm = `OP_FALSE OP_RETURN ${mapPrefixHex} ${mapCmdValue}`; 71 | 72 | for (const [key, value] of Object.entries(metaData)) { 73 | if (key !== "cmd") { 74 | metaAsm = `${metaAsm} ${toHex(key)} ${toHex(value as string)}`; 75 | } 76 | } 77 | } 78 | 79 | tx.addOutput({ 80 | satoshis: 0, 81 | lockingScript: Script.fromASM(metaAsm || "OP_FALSE OP_RETURN"), 82 | }); 83 | 84 | // Sign the transaction 85 | await tx.sign(); 86 | 87 | return { 88 | tx, 89 | spentOutpoints, 90 | }; 91 | }; 92 | -------------------------------------------------------------------------------- /src/cancelListings.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, } from "bun:test"; 2 | import { PrivateKey, Transaction } from "@bsv/sdk"; 3 | import { cancelOrdListings, cancelOrdTokenListings } from "./cancelListings"; 4 | import { type CancelOrdListingsConfig, type CancelOrdTokenListingsConfig, type Utxo, type TokenUtxo, TokenType } from "./types"; 5 | 6 | describe("cancelOrdListings", () => { 7 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 8 | const ordPk = PrivateKey.fromWif("L5mDYNS6Dqjy72LA66sJ6V7APxgKF3DHXUagKbf7q4ctv9c9Rwpb"); 9 | const address = paymentPk.toAddress().toString(); 10 | 11 | const utxos: Utxo[] = [{ 12 | satoshis: 10000, 13 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 14 | vout: 0, 15 | script: "base64EncodedScript", 16 | }]; 17 | 18 | const listingUtxos: Utxo[] = [{ 19 | satoshis: 1, 20 | txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 21 | vout: 0, 22 | script: "base64EncodedScript", 23 | }]; 24 | 25 | const baseConfig: CancelOrdListingsConfig = { 26 | utxos, 27 | paymentPk, 28 | ordPk, 29 | listingUtxos, 30 | additionalPayments: [], 31 | }; 32 | 33 | test("cancel ord listings with sufficient funds", async () => { 34 | const { tx, spentOutpoints, payChange } = await cancelOrdListings(baseConfig); 35 | 36 | expect(tx).toBeInstanceOf(Transaction); 37 | expect(spentOutpoints).toHaveLength(2); // 1 payment utxo + 1 listing utxo 38 | expect(payChange).toBeDefined(); 39 | }); 40 | 41 | test("cancel ord listings with additional payments", async () => { 42 | const config = { 43 | ...baseConfig, 44 | additionalPayments: [{ to: address, amount: 1000 }], 45 | }; 46 | const { tx } = await cancelOrdListings(config); 47 | console.log("cancel rawtx", tx.toHex()) 48 | expect(tx.outputs).toHaveLength(3); // 1 for ordinal return, 1 for additional payment, 1 for change 49 | }); 50 | 51 | test("cancel ord listings with insufficient funds", async () => { 52 | const insufficientConfig = { 53 | ...baseConfig, 54 | utxos: [{ ...utxos[0], satoshis: 1 }], 55 | }; 56 | expect(cancelOrdListings(insufficientConfig)).rejects.toThrow("Not enough funds"); 57 | }); 58 | }); 59 | 60 | describe("cancelOrdTokenListings", () => { 61 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 62 | const ordPk = PrivateKey.fromWif("L5mDYNS6Dqjy72LA66sJ6V7APxgKF3DHXUagKbf7q4ctv9c9Rwpb"); 63 | const address = paymentPk.toAddress().toString(); 64 | 65 | const utxos: Utxo[] = [{ 66 | satoshis: 10000, 67 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 68 | vout: 0, 69 | script: "base64EncodedScript", 70 | }]; 71 | 72 | const listingUtxos: TokenUtxo[] = [{ 73 | satoshis: 1, 74 | txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 75 | vout: 0, 76 | script: "base64EncodedScript", 77 | amt: "1000", 78 | id: "TOKEN123", 79 | }]; 80 | 81 | const baseConfig: CancelOrdTokenListingsConfig = { 82 | utxos, 83 | paymentPk, 84 | ordPk, 85 | listingUtxos, 86 | additionalPayments: [], 87 | protocol: TokenType.BSV20, 88 | tokenID: "TOKEN123", 89 | }; 90 | 91 | test("cancel ord token listings with sufficient funds", async () => { 92 | const { tx, spentOutpoints, payChange } = await cancelOrdTokenListings(baseConfig); 93 | 94 | expect(tx).toBeInstanceOf(Transaction); 95 | expect(spentOutpoints).toHaveLength(2); // 1 payment utxo + 1 listing utxo 96 | expect(payChange).toBeDefined(); 97 | }); 98 | 99 | test("cancel ord token listings with BSV21 protocol", async () => { 100 | const bsv21Config = { 101 | ...baseConfig, 102 | protocol: TokenType.BSV21, 103 | }; 104 | const { tx } = await cancelOrdTokenListings(bsv21Config); 105 | 106 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("bsv-20").toString('hex')); 107 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("id").toString('hex')); 108 | }); 109 | 110 | test("cancel ord token listings with mismatched tokenID", async () => { 111 | const mismatchedConfig = { 112 | ...baseConfig, 113 | listingUtxos: [{ ...listingUtxos[0], id: "WRONGTOKEN" }], 114 | }; 115 | await expect(cancelOrdTokenListings(mismatchedConfig)).rejects.toThrow("Input tokens do not match"); 116 | }); 117 | 118 | test("cancel ord token listings with insufficient funds", async () => { 119 | const insufficientConfig = { 120 | ...baseConfig, 121 | utxos: [{ ...utxos[0], satoshis: 1 }], 122 | }; 123 | await expect(cancelOrdTokenListings(insufficientConfig)).rejects.toThrow("Not enough funds"); 124 | }); 125 | }); -------------------------------------------------------------------------------- /src/cancelListings.ts: -------------------------------------------------------------------------------- 1 | import { P2PKH, SatoshisPerKilobyte, Script, Transaction, Utils } from "@bsv/sdk"; 2 | import { 3 | TokenType, 4 | type TokenUtxo, 5 | type CancelOrdListingsConfig, 6 | type CancelOrdTokenListingsConfig, 7 | type Destination, 8 | type TransferBSV20Inscription, 9 | type TransferBSV21Inscription, 10 | type TransferTokenInscription, 11 | type Utxo, 12 | type ChangeResult, 13 | type TokenChangeResult, 14 | } from "./types"; 15 | import { inputFromB64Utxo } from "./utils/utxo"; 16 | import { DEFAULT_SAT_PER_KB } from "./constants"; 17 | import OrdLock from "./templates/ordLock"; 18 | import OrdP2PKH from "./templates/ordP2pkh"; 19 | 20 | /** 21 | * Cancel Ordinal Listings 22 | * @param {CancelOrdListingsConfig} config - Configuration object for cancelling ordinals 23 | * @param {PrivateKey} config.paymentPk - Private key to sign payment inputs 24 | * @param {PrivateKey} config.ordPk - Private key to sign ordinals 25 | * @param {Utxo[]} config.utxos - Utxos to spend (with base64 encoded scripts) 26 | * @param {Utxo[]} config.listingUtxos - Listing utxos to cancel (with base64 encoded scripts) 27 | * @param {string} [config.changeAddress] - Optional. Address to send change to 28 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation 29 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to make 30 | * @returns {Promise} Transaction, spent outpoints, change utxo 31 | */ 32 | export const cancelOrdListings = async (config: CancelOrdListingsConfig): Promise => { 33 | const { 34 | utxos, 35 | listingUtxos, 36 | ordPk, 37 | paymentPk, 38 | additionalPayments = [], 39 | satsPerKb = DEFAULT_SAT_PER_KB, 40 | } = config; 41 | 42 | // Warn if creating many inscriptions at once 43 | if (listingUtxos.length > 100) { 44 | console.warn( 45 | "Creating many inscriptions at once can be slow. Consider using multiple transactions instead.", 46 | ); 47 | } 48 | 49 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 50 | const tx = new Transaction(); 51 | 52 | // Inputs 53 | // Add the locked ordinals we're cancelling 54 | for (const listingUtxo of listingUtxos) { 55 | const ordKeyToUse = listingUtxo.pk || ordPk; 56 | if(!ordKeyToUse) { 57 | throw new Error("Private key required for token input"); 58 | } 59 | tx.addInput(inputFromB64Utxo( 60 | listingUtxo, 61 | new OrdLock().cancelListing( 62 | ordKeyToUse, 63 | "all", 64 | true, 65 | listingUtxo.satoshis, 66 | Script.fromBinary(Utils.toArray(listingUtxo.script, 'base64')) 67 | ) 68 | )); 69 | // Add cancel outputs returning listed ordinals 70 | tx.addOutput({ 71 | satoshis: 1, 72 | lockingScript: new P2PKH().lock((ordKeyToUse).toAddress().toString()), 73 | }); 74 | } 75 | 76 | // Add additional payments if any 77 | for (const p of additionalPayments) { 78 | tx.addOutput({ 79 | satoshis: p.amount, 80 | lockingScript: new P2PKH().lock(p.to), 81 | }); 82 | } 83 | 84 | // add change to the outputs 85 | let payChange: Utxo | undefined; 86 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 87 | if (!changeAddress) { 88 | throw new Error("paymentPk or changeAddress required for payment change"); 89 | } 90 | const change = changeAddress; 91 | const changeScript = new P2PKH().lock(change); 92 | const changeOut = { 93 | lockingScript: changeScript, 94 | change: true, 95 | }; 96 | tx.addOutput(changeOut); 97 | 98 | let totalSatsIn = 0n; 99 | const totalSatsOut = tx.outputs.reduce( 100 | (total, out) => total + BigInt(out.satoshis || 0), 101 | 0n, 102 | ); 103 | let fee = 0; 104 | for (const utxo of utxos) { 105 | const payKeyToUse = utxo.pk || paymentPk; 106 | if(!payKeyToUse) { 107 | throw new Error("paymentPk required for payment utxo"); 108 | } 109 | const input = inputFromB64Utxo( 110 | utxo, 111 | new P2PKH().unlock( 112 | payKeyToUse, 113 | "all", 114 | true, 115 | utxo.satoshis, 116 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 117 | ) 118 | ); 119 | 120 | tx.addInput(input); 121 | // stop adding inputs if the total amount is enough 122 | totalSatsIn += BigInt(utxo.satoshis); 123 | fee = await modelOrFee.computeFee(tx); 124 | 125 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 126 | break; 127 | } 128 | } 129 | 130 | // make sure we have enough 131 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 132 | throw new Error( 133 | `Not enough funds to cancel ordinal listings. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 134 | ); 135 | } 136 | 137 | // estimate the cost of the transaction and assign change value 138 | await tx.fee(modelOrFee); 139 | 140 | // Sign the transaction 141 | await tx.sign(); 142 | 143 | // check for change 144 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 145 | if (payChangeOutIdx !== -1) { 146 | const changeOutput = tx.outputs[payChangeOutIdx]; 147 | payChange = { 148 | satoshis: changeOutput.satoshis as number, 149 | txid: tx.id("hex") as string, 150 | vout: payChangeOutIdx, 151 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 152 | "base64", 153 | ), 154 | }; 155 | } 156 | 157 | if (payChange) { 158 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 159 | payChange.satoshis = changeOutput.satoshis as number; 160 | payChange.txid = tx.id("hex") as string; 161 | } 162 | 163 | return { 164 | tx, 165 | spentOutpoints: tx.inputs.map( 166 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 167 | ), 168 | payChange, 169 | }; 170 | }; 171 | 172 | /** 173 | * Cancel Ordinal Token Listings 174 | * @param {CancelOrdTokenListingsConfig} config - Configuration object for cancelling token ordinals 175 | * @param {PrivateKey} config.paymentPk - Private key to sign payment inputs 176 | * @param {PrivateKey} config.ordPk - Private key to sign ordinals 177 | * @param {Utxo[]} config.utxos - Utxos to spend (with base64 encoded scripts) 178 | * @param {Utxo[]} config.listingUtxos - Listing utxos to cancel (with base64 encoded scripts) 179 | * @param {string} config.tokenID - Token ID of the token to cancel listings for 180 | * @param {string} config.ordAddress - Address to send the cancelled token to 181 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation 182 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to make 183 | * @returns {Promise} Transaction, spent outpoints, change utxo, token change utxos 184 | */ 185 | export const cancelOrdTokenListings = async ( 186 | config: CancelOrdTokenListingsConfig, 187 | ): Promise => { 188 | const { 189 | protocol, 190 | tokenID, 191 | paymentPk, 192 | ordPk, 193 | additionalPayments, 194 | listingUtxos, 195 | utxos, 196 | satsPerKb = DEFAULT_SAT_PER_KB, 197 | } = config; 198 | // calculate change amount 199 | let totalAmtIn = 0; 200 | 201 | if (listingUtxos.length > 100) { 202 | console.warn( 203 | "Creating many inscriptions at once can be slow. Consider using multiple transactions instead.", 204 | ); 205 | } 206 | 207 | // Ensure these inputs are for the expected token 208 | if (!listingUtxos.every((token) => token.id === tokenID)) { 209 | throw new Error("Input tokens do not match the provided tokenID"); 210 | } 211 | 212 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 213 | const tx = new Transaction(); 214 | 215 | // Inputs 216 | // Add the locked ordinals we're cancelling 217 | for (const listingUtxo of listingUtxos) { 218 | const ordKeyToUse = listingUtxo.pk || ordPk; 219 | if(!ordKeyToUse) { 220 | throw new Error("Private key required for token input"); 221 | } 222 | tx.addInput(inputFromB64Utxo( 223 | listingUtxo, 224 | new OrdLock().cancelListing( 225 | ordKeyToUse, 226 | "all", 227 | true, 228 | listingUtxo.satoshis, 229 | Script.fromBinary(Utils.toArray(listingUtxo.script, 'base64')) 230 | ) 231 | )); 232 | totalAmtIn += Number.parseInt(listingUtxo.amt); 233 | } 234 | 235 | const transferInscription: TransferTokenInscription = { 236 | p: "bsv-20", 237 | op: "transfer", 238 | amt: totalAmtIn.toString(), 239 | }; 240 | let inscription: TransferBSV20Inscription | TransferBSV21Inscription; 241 | if (protocol === TokenType.BSV20) { 242 | inscription = { 243 | ...transferInscription, 244 | tick: tokenID, 245 | } as TransferBSV20Inscription; 246 | } else if (protocol === TokenType.BSV21) { 247 | inscription = { 248 | ...transferInscription, 249 | id: tokenID, 250 | } as TransferBSV21Inscription; 251 | } else { 252 | throw new Error("Invalid protocol"); 253 | } 254 | 255 | const ordAddress = config.ordAddress || ordPk?.toAddress(); 256 | if(!ordAddress) { 257 | throw new Error("ordAddress or ordPk required for token output"); 258 | } 259 | const destination: Destination = { 260 | address: ordAddress, 261 | inscription: { 262 | dataB64: Buffer.from(JSON.stringify(inscription)).toString("base64"), 263 | contentType: "application/bsv-20", 264 | }, 265 | }; 266 | 267 | const lockingScript = new OrdP2PKH().lock( 268 | destination.address, 269 | destination.inscription 270 | ); 271 | 272 | tx.addOutput({ 273 | satoshis: 1, 274 | lockingScript, 275 | }); 276 | 277 | // Add additional payments if any 278 | for (const p of additionalPayments) { 279 | tx.addOutput({ 280 | satoshis: p.amount, 281 | lockingScript: new P2PKH().lock(p.to), 282 | }); 283 | } 284 | 285 | // add change to the outputs 286 | let payChange: Utxo | undefined; 287 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 288 | if (!changeAddress) { 289 | throw new Error("paymentPk or changeAddress required for payment change"); 290 | } 291 | const changeScript = new P2PKH().lock(changeAddress); 292 | const changeOut = { 293 | lockingScript: changeScript, 294 | change: true, 295 | }; 296 | tx.addOutput(changeOut); 297 | 298 | let totalSatsIn = 0n; 299 | const totalSatsOut = tx.outputs.reduce( 300 | (total, out) => total + BigInt(out.satoshis || 0), 301 | 0n, 302 | ); 303 | let fee = 0; 304 | for (const utxo of utxos) { 305 | const payKeyToUse = utxo.pk || paymentPk; 306 | if(!payKeyToUse) { 307 | throw new Error("paymentPk required for payment utxo"); 308 | } 309 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 310 | payKeyToUse, 311 | "all", 312 | true, 313 | utxo.satoshis, 314 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 315 | )); 316 | 317 | tx.addInput(input); 318 | // stop adding inputs if the total amount is enough 319 | totalSatsIn += BigInt(utxo.satoshis); 320 | fee = await modelOrFee.computeFee(tx); 321 | 322 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 323 | break; 324 | } 325 | } 326 | 327 | // make sure we have enough 328 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 329 | throw new Error( 330 | `Not enough funds to cancel token listings. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 331 | ); 332 | } 333 | 334 | // estimate the cost of the transaction and assign change value 335 | await tx.fee(modelOrFee); 336 | 337 | // Sign the transaction 338 | await tx.sign(); 339 | 340 | const tokenChange: TokenUtxo[] = [{ 341 | amt: totalAmtIn.toString(), 342 | script: Buffer.from(lockingScript.toHex(), 'hex').toString('base64'), 343 | txid: tx.id("hex") as string, 344 | vout: 0, 345 | id: tokenID, 346 | satoshis: 1 347 | }]; 348 | 349 | // check for change 350 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 351 | if (payChangeOutIdx !== -1) { 352 | const changeOutput = tx.outputs[payChangeOutIdx]; 353 | payChange = { 354 | satoshis: changeOutput.satoshis as number, 355 | txid: tx.id("hex") as string, 356 | vout: payChangeOutIdx, 357 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 358 | "base64", 359 | ), 360 | }; 361 | } 362 | 363 | if (payChange) { 364 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 365 | payChange.satoshis = changeOutput.satoshis as number; 366 | payChange.txid = tx.id("hex") as string; 367 | } 368 | 369 | return { 370 | tx, 371 | spentOutpoints: tx.inputs.map( 372 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 373 | ), 374 | payChange, 375 | tokenChange, 376 | }; 377 | }; -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAP_PREFIX = "1PuQa7K62MiKCtssSLKy1kh56WWU7MtUR5"; 2 | export const DEFAULT_SAT_PER_KB = 10; 3 | export const API_HOST = "https://ordinals.gorillapool.io/api"; -------------------------------------------------------------------------------- /src/createListings.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { PrivateKey, Transaction } from "@bsv/sdk"; 3 | import { createOrdListings, createOrdTokenListings } from "./createListings"; 4 | import { 5 | TokenType, 6 | type CreateOrdListingsConfig, 7 | type CreateOrdTokenListingsConfig, 8 | type NewListing, 9 | type NewTokenListing, 10 | type TokenUtxo, 11 | type Utxo 12 | } from "./types"; 13 | 14 | describe("createOrdListings", () => { 15 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 16 | const ordPk = PrivateKey.fromWif("L5mDYNS6Dqjy72LA66sJ6V7APxgKF3DHXUagKbf7q4ctv9c9Rwpb"); 17 | const address = paymentPk.toAddress().toString(); 18 | 19 | const utxos: Utxo[] = [{ 20 | satoshis: 10000, 21 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 22 | vout: 0, 23 | script: "base64EncodedScript", 24 | }]; 25 | 26 | const listings: NewListing[] = [{ 27 | payAddress: address, 28 | ordAddress: ordPk.toAddress().toString(), 29 | price: 5000, 30 | listingUtxo: { 31 | satoshis: 1, 32 | txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 33 | vout: 0, 34 | script: "base64EncodedScript", 35 | }, 36 | }]; 37 | 38 | const baseConfig: CreateOrdListingsConfig = { 39 | utxos, 40 | listings, 41 | paymentPk, 42 | ordPk, 43 | }; 44 | 45 | test("create ord listings with sufficient funds", async () => { 46 | const { tx, spentOutpoints, payChange } = await createOrdListings(baseConfig); 47 | 48 | expect(tx).toBeInstanceOf(Transaction); 49 | expect(spentOutpoints).toHaveLength(2); // 1 payment utxo + 1 listing utxo 50 | expect(payChange).toBeDefined(); 51 | }); 52 | 53 | test("create ord listings with additional payments", async () => { 54 | const config = { 55 | ...baseConfig, 56 | additionalPayments: [{ to: address, amount: 1000 }], 57 | }; 58 | const { tx } = await createOrdListings(config); 59 | expect(tx.outputs).toHaveLength(3); // 1 for listing, 1 for additional payment, 1 for change 60 | }); 61 | 62 | test("create ord listings with insufficient funds", async () => { 63 | const insufficientConfig = { 64 | ...baseConfig, 65 | utxos: [{ ...utxos[0], satoshis: 1 }], 66 | }; 67 | await expect(createOrdListings(insufficientConfig)).rejects.toThrow("Not enough funds"); 68 | }); 69 | }); 70 | 71 | describe("createOrdTokenListings", () => { 72 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 73 | const ordPk = PrivateKey.fromWif("L5mDYNS6Dqjy72LA66sJ6V7APxgKF3DHXUagKbf7q4ctv9c9Rwpb"); 74 | const address = paymentPk.toAddress().toString(); 75 | 76 | const utxos: Utxo[] = [{ 77 | satoshis: 10000, 78 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 79 | vout: 0, 80 | script: "base64EncodedScript", 81 | }]; 82 | 83 | const inputTokens: TokenUtxo[] = [{ 84 | satoshis: 1, 85 | txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 86 | vout: 0, 87 | script: "base64EncodedScript", 88 | amt: "200000000000", 89 | id: "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0", 90 | }]; 91 | 92 | const listings: NewTokenListing[] = [{ 93 | payAddress: address, 94 | ordAddress: ordPk.toAddress().toString(), 95 | tokens: 1000, 96 | price: 5000, 97 | }]; 98 | 99 | const baseConfig: CreateOrdTokenListingsConfig = { 100 | utxos, 101 | listings, 102 | paymentPk, 103 | ordPk, 104 | protocol: TokenType.BSV20, 105 | tokenID: "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0", 106 | inputTokens, 107 | tokenChangeAddress: address, 108 | decimals: 8, 109 | }; 110 | 111 | test("create ord token listings with sufficient funds", async () => { 112 | const { tx, spentOutpoints, payChange, tokenChange } = await createOrdTokenListings(baseConfig); 113 | 114 | expect(tx).toBeInstanceOf(Transaction); 115 | expect(spentOutpoints).toHaveLength(2); // 1 payment utxo + 1 token utxo 116 | expect(payChange).toBeDefined(); 117 | expect(tokenChange).toBeDefined(); 118 | if (tokenChange) { 119 | expect(tokenChange[0].amt).toBe("100000000000"); 120 | expect(tokenChange[0].txid).toBe(tx.id('hex')); 121 | } 122 | }); 123 | 124 | test("create ord token listings with BSV21 protocol", async () => { 125 | const bsv21Config = { 126 | ...baseConfig, 127 | protocol: TokenType.BSV21, 128 | }; 129 | const { tx } = await createOrdTokenListings(bsv21Config); 130 | 131 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("bsv-20").toString('hex')); 132 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("id").toString('hex')); 133 | }); 134 | 135 | test("create ord token listings with mismatched tokenID", async () => { 136 | const mismatchedConfig = { 137 | ...baseConfig, 138 | inputTokens: [{ ...inputTokens[0], id: "WRONGTOKEN" }], 139 | }; 140 | await expect(createOrdTokenListings(mismatchedConfig)).rejects.toThrow("Input tokens do not match"); 141 | }); 142 | 143 | test("create ord token listings with insufficient tokens", async () => { 144 | const insufficientConfig = { 145 | ...baseConfig, 146 | inputTokens: [{ ...inputTokens[0], amt: "500" }], 147 | }; 148 | await expect(createOrdTokenListings(insufficientConfig)).rejects.toThrow("Not enough tokens to send"); 149 | }); 150 | 151 | test("create ord token listings with insufficient funds", async () => { 152 | const insufficientConfig = { 153 | ...baseConfig, 154 | utxos: [{ ...utxos[0], satoshis: 1 }], 155 | }; 156 | await expect(createOrdTokenListings(insufficientConfig)).rejects.toThrow("Not enough funds"); 157 | }); 158 | }); -------------------------------------------------------------------------------- /src/createListings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | P2PKH, 3 | SatoshisPerKilobyte, 4 | Script, 5 | Transaction, 6 | Utils, 7 | } from "@bsv/sdk"; 8 | import { DEFAULT_SAT_PER_KB } from "./constants"; 9 | import OrdLock from "./templates/ordLock"; 10 | import OrdP2PKH from "./templates/ordP2pkh"; 11 | import { 12 | type TokenChangeResult, 13 | TokenType, 14 | type CreateOrdListingsConfig, 15 | type CreateOrdTokenListingsConfig, 16 | type TokenUtxo, 17 | type TransferBSV20Inscription, 18 | type TransferBSV21Inscription, 19 | type TransferTokenInscription, 20 | type Utxo, 21 | } from "./types"; 22 | import { inputFromB64Utxo } from "./utils/utxo"; 23 | import { ReturnTypes, toToken, toTokenSat } from "satoshi-token"; 24 | const { toArray } = Utils; 25 | 26 | export const createOrdListings = async (config: CreateOrdListingsConfig) => { 27 | const { 28 | utxos, 29 | listings, 30 | paymentPk, 31 | ordPk, 32 | satsPerKb = DEFAULT_SAT_PER_KB, 33 | additionalPayments = [], 34 | } = config; 35 | 36 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 37 | const tx = new Transaction(); 38 | 39 | // Warn if creating many inscriptions at once 40 | if (listings.length > 100) { 41 | console.warn( 42 | "Creating many inscriptions at once can be slow. Consider using multiple transactions instead.", 43 | ); 44 | } 45 | 46 | // Outputs 47 | // Add listing outputs 48 | for (const listing of listings) { 49 | tx.addOutput({ 50 | satoshis: 1, 51 | lockingScript: new OrdLock().lock( 52 | listing.ordAddress, 53 | listing.payAddress, 54 | listing.price, 55 | ), 56 | }); 57 | const inputScriptBinary = toArray(listing.listingUtxo.script, "base64"); 58 | const inputScript = Script.fromBinary(inputScriptBinary); 59 | 60 | const ordKeyToUse = listing.listingUtxo.pk || ordPk; 61 | if (!ordKeyToUse) { 62 | throw new Error("Private key is required to sign the ordinal"); 63 | } 64 | tx.addInput(inputFromB64Utxo( 65 | listing.listingUtxo, 66 | new OrdP2PKH().unlock( 67 | ordKeyToUse, 68 | "all", 69 | true, 70 | listing.listingUtxo.satoshis, 71 | inputScript, 72 | ), 73 | )); 74 | } 75 | 76 | // Add additional payments if any 77 | for (const p of additionalPayments) { 78 | tx.addOutput({ 79 | satoshis: p.amount, 80 | lockingScript: new P2PKH().lock(p.to), 81 | }); 82 | } 83 | 84 | // Check if change is needed 85 | let payChange: Utxo | undefined; 86 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 87 | if(!changeAddress) { 88 | throw new Error("changeAddress or private key is required"); 89 | } 90 | const changeScript = new P2PKH().lock(changeAddress); 91 | const changeOutput = { 92 | lockingScript: changeScript, 93 | change: true, 94 | }; 95 | tx.addOutput(changeOutput); 96 | 97 | let totalSatsIn = 0n; 98 | const totalSatsOut = tx.outputs.reduce( 99 | (total, out) => total + BigInt(out.satoshis || 0), 100 | 0n, 101 | ); 102 | let fee = 0; 103 | for (const utxo of utxos) { 104 | const payKeyToUse = utxo.pk || paymentPk; 105 | if (!payKeyToUse) { 106 | throw new Error("Private key is required to sign the transaction"); 107 | } 108 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 109 | payKeyToUse, 110 | "all", 111 | true, 112 | utxo.satoshis, 113 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 114 | )); 115 | 116 | tx.addInput(input); 117 | // stop adding inputs if the total amount is enough 118 | totalSatsIn += BigInt(utxo.satoshis); 119 | fee = await modelOrFee.computeFee(tx); 120 | 121 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 122 | break; 123 | } 124 | } 125 | 126 | // make sure we have enough 127 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 128 | throw new Error( 129 | `Not enough funds to create ordinal listings. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 130 | ); 131 | } 132 | 133 | // Calculate fee 134 | await tx.fee(modelOrFee); 135 | 136 | // Sign the transaction 137 | await tx.sign(); 138 | 139 | // check for change 140 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 141 | if (payChangeOutIdx !== -1) { 142 | const changeOutput = tx.outputs[payChangeOutIdx]; 143 | payChange = { 144 | satoshis: changeOutput.satoshis as number, 145 | txid: tx.id("hex") as string, 146 | vout: payChangeOutIdx, 147 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 148 | "base64", 149 | ), 150 | }; 151 | } 152 | 153 | if (payChange) { 154 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 155 | payChange.satoshis = changeOutput.satoshis as number; 156 | payChange.txid = tx.id("hex") as string; 157 | } 158 | 159 | return { 160 | tx, 161 | spentOutpoints: tx.inputs.map( 162 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 163 | ), 164 | payChange, 165 | }; 166 | }; 167 | 168 | export const createOrdTokenListings = async ( 169 | config: CreateOrdTokenListingsConfig, 170 | ): Promise => { 171 | const { 172 | utxos, 173 | protocol, 174 | tokenID, 175 | ordPk, 176 | paymentPk, 177 | additionalPayments = [], 178 | tokenChangeAddress, 179 | inputTokens, 180 | listings, 181 | decimals, 182 | satsPerKb = DEFAULT_SAT_PER_KB, 183 | } = config; 184 | 185 | 186 | // Warn if creating many inscriptions at once 187 | if (listings.length > 100) { 188 | console.warn( 189 | "Creating many inscriptions at once can be slow. Consider using multiple transactions instead.", 190 | ); 191 | } 192 | 193 | // Ensure these inputs are for the expected token 194 | if (!inputTokens.every((token) => token.id === tokenID)) { 195 | throw new Error("Input tokens do not match the provided tokenID"); 196 | } 197 | 198 | // calculate change amount 199 | let changeAmt = 0n; 200 | let totalAmtIn = 0n; 201 | let totalAmtOut = 0n; 202 | 203 | // Ensure these inputs are for the expected token 204 | if (!inputTokens.every((token) => token.id === tokenID)) { 205 | throw new Error("Input tokens do not match the provided tokenID"); 206 | } 207 | 208 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 209 | const tx = new Transaction(); 210 | // Outputs 211 | // Add listing outputs 212 | for (const listing of listings) { 213 | // NewTokenListing is not adjusted for decimals 214 | const bigAmt = toTokenSat(listing.tokens, decimals, ReturnTypes.BigInt); 215 | const transferInscription: TransferTokenInscription = { 216 | p: "bsv-20", 217 | op: "transfer", 218 | amt: bigAmt.toString(), 219 | }; 220 | let inscription: TransferBSV20Inscription | TransferBSV21Inscription; 221 | if (protocol === TokenType.BSV20) { 222 | inscription = { 223 | ...transferInscription, 224 | tick: tokenID, 225 | } as TransferBSV20Inscription; 226 | } else if (protocol === TokenType.BSV21) { 227 | inscription = { 228 | ...transferInscription, 229 | id: tokenID, 230 | } as TransferBSV21Inscription; 231 | } else { 232 | throw new Error("Invalid protocol"); 233 | } 234 | 235 | tx.addOutput({ 236 | satoshis: 1, 237 | lockingScript: new OrdLock().lock( 238 | listing.ordAddress, 239 | listing.payAddress, 240 | listing.price, 241 | { 242 | dataB64: Buffer.from(JSON.stringify(inscription)).toString("base64"), 243 | contentType: "application/bsv-20", 244 | }, 245 | ), 246 | }); 247 | totalAmtOut += bigAmt; 248 | } 249 | 250 | // Input tokens are already adjusted for decimals 251 | for (const token of inputTokens) { 252 | const ordKeyToUse = token.pk || ordPk; 253 | if(!ordKeyToUse) { 254 | throw new Error("Private key is required to sign the ordinal"); 255 | } 256 | tx.addInput(inputFromB64Utxo( 257 | token, 258 | new OrdP2PKH().unlock( 259 | ordKeyToUse, 260 | "all", 261 | true, 262 | token.satoshis, 263 | Script.fromBinary(toArray(token.script, "base64")), 264 | ), 265 | )); 266 | 267 | totalAmtIn += BigInt(token.amt); 268 | } 269 | changeAmt = totalAmtIn - totalAmtOut; 270 | 271 | let tokenChange: TokenUtxo[] | undefined; 272 | // check that you have enough tokens to send and return change 273 | if (changeAmt < 0n) { 274 | throw new Error("Not enough tokens to send"); 275 | } 276 | if (changeAmt > 0n) { 277 | const transferInscription: TransferTokenInscription = { 278 | p: "bsv-20", 279 | op: "transfer", 280 | amt: changeAmt.toString(), 281 | }; 282 | let inscription: TransferBSV20Inscription | TransferBSV21Inscription; 283 | if (protocol === TokenType.BSV20) { 284 | inscription = { 285 | ...transferInscription, 286 | tick: tokenID, 287 | } as TransferBSV20Inscription; 288 | } else if (protocol === TokenType.BSV21) { 289 | inscription = { 290 | ...transferInscription, 291 | id: tokenID, 292 | } as TransferBSV21Inscription; 293 | } else { 294 | throw new Error("Invalid protocol"); 295 | } 296 | 297 | const lockingScript = new OrdP2PKH().lock(tokenChangeAddress, { 298 | dataB64: Buffer.from(JSON.stringify(inscription)).toString('base64'), 299 | contentType: "application/bsv-20", 300 | }); 301 | const vout = tx.outputs.length; 302 | tx.addOutput({ lockingScript, satoshis: 1 }); 303 | tokenChange = [{ 304 | id: tokenID, 305 | satoshis: 1, 306 | script: Buffer.from(lockingScript.toBinary()).toString("base64"), 307 | txid: "", 308 | vout, 309 | amt: changeAmt.toString(), 310 | }]; 311 | } 312 | 313 | // Add additional payments if any 314 | for (const p of additionalPayments) { 315 | tx.addOutput({ 316 | satoshis: p.amount, 317 | lockingScript: new P2PKH().lock(p.to), 318 | }); 319 | } 320 | 321 | // add change to the outputs 322 | let payChange: Utxo | undefined; 323 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 324 | if(!changeAddress) { 325 | throw new Error("Either changeAddress or paymentPk is required"); 326 | } 327 | 328 | const changeScript = new P2PKH().lock(changeAddress); 329 | const changeOut = { 330 | lockingScript: changeScript, 331 | change: true, 332 | }; 333 | tx.addOutput(changeOut); 334 | 335 | let totalSatsIn = 0n; 336 | const totalSatsOut = tx.outputs.reduce( 337 | (total, out) => total + BigInt(out.satoshis || 0), 338 | 0n, 339 | ); 340 | let fee = 0; 341 | for (const utxo of utxos) { 342 | const payKeyToUse = utxo.pk || paymentPk; 343 | if(!payKeyToUse) { 344 | throw new Error("Private key is required to sign the payment"); 345 | } 346 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 347 | payKeyToUse, 348 | "all", 349 | true, 350 | utxo.satoshis, 351 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 352 | )); 353 | 354 | tx.addInput(input); 355 | // stop adding inputs if the total amount is enough 356 | totalSatsIn += BigInt(utxo.satoshis); 357 | fee = await modelOrFee.computeFee(tx); 358 | 359 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 360 | break; 361 | } 362 | } 363 | 364 | // make sure we have enough 365 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 366 | throw new Error( 367 | `Not enough funds to create token listings. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 368 | ); 369 | } 370 | 371 | // estimate the cost of the transaction and assign change value 372 | await tx.fee(modelOrFee); 373 | 374 | // Sign the transaction 375 | await tx.sign(); 376 | 377 | const txid = tx.id("hex") as string; 378 | if (tokenChange) { 379 | tokenChange = tokenChange.map((tc) => ({ ...tc, txid })); 380 | } 381 | // check for change 382 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 383 | if (payChangeOutIdx !== -1) { 384 | const changeOutput = tx.outputs[payChangeOutIdx]; 385 | payChange = { 386 | satoshis: changeOutput.satoshis as number, 387 | txid, 388 | vout: payChangeOutIdx, 389 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 390 | "base64", 391 | ), 392 | }; 393 | } 394 | 395 | if (payChange) { 396 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 397 | payChange.satoshis = changeOutput.satoshis as number; 398 | payChange.txid = tx.id("hex") as string; 399 | } 400 | 401 | return { 402 | tx, 403 | spentOutpoints: tx.inputs.map( 404 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 405 | ), 406 | payChange, 407 | tokenChange, 408 | }; 409 | }; 410 | -------------------------------------------------------------------------------- /src/createOrdinals.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { PrivateKey, Script, Transaction, Utils } from "@bsv/sdk"; 3 | import { createOrdinals } from "./createOrdinals"; 4 | import type { CreateOrdinalsConfig, Utxo, Destination, Inscription, PreMAP } from "./types"; 5 | 6 | describe("createOrdinals", () => { 7 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 8 | const address = paymentPk.toAddress().toString(); 9 | 10 | const utxos: Utxo[] = [{ 11 | satoshis: 10000, 12 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 13 | vout: 0, 14 | script: "base64EncodedScript", 15 | }]; 16 | 17 | const inscription: Inscription = { 18 | dataB64: Buffer.from("Test Inscription").toString("base64"), 19 | contentType: "text/plain", 20 | }; 21 | 22 | const destinations: Destination[] = [{ 23 | address, 24 | inscription, 25 | }]; 26 | 27 | const baseConfig: CreateOrdinalsConfig = { 28 | utxos, 29 | destinations, 30 | paymentPk, 31 | }; 32 | 33 | test("create ordinals with sufficient funds", async () => { 34 | const { tx, spentOutpoints, payChange } = await createOrdinals(baseConfig); 35 | 36 | expect(tx).toBeInstanceOf(Transaction); 37 | expect(spentOutpoints).toHaveLength(1); 38 | expect(payChange).toBeDefined(); 39 | }); 40 | 41 | test("create ordinals with additional payments", async () => { 42 | const config = { 43 | ...baseConfig, 44 | additionalPayments: [{ to: address, amount: 1000 }], 45 | }; 46 | const { tx } = await createOrdinals(config); 47 | 48 | expect(tx.outputs).toHaveLength(3); // 1 for inscription, 1 for additional payment, 1 for change 49 | }); 50 | 51 | test("create ordinals with metadata", async () => { 52 | const metaData: PreMAP = { 53 | name: "Test Ordinal", 54 | description: "This is a test ordinal", 55 | app: "js-1sat-ord-test", 56 | type: "ord" 57 | }; 58 | const config: CreateOrdinalsConfig = { 59 | ...baseConfig, 60 | metaData, 61 | }; 62 | const { tx } = await createOrdinals(config); 63 | 64 | // Check if metadata is included in the output script 65 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("Test Ordinal").toString("hex")); 66 | }); 67 | 68 | test("create ordinals with insufficient funds", async () => { 69 | const insufficientConfig = { 70 | ...baseConfig, 71 | utxos: [{ ...utxos[0], satoshis: 1 }], 72 | }; 73 | await expect(createOrdinals(insufficientConfig)).rejects.toThrow(); 74 | }); 75 | 76 | test("create multiple ordinals", async () => { 77 | const multipleDestinations = [ 78 | ...destinations, 79 | { address, inscription: { ...inscription, dataB64: Buffer.from("Second Inscription").toString("base64") } }, 80 | ]; 81 | const config = { 82 | ...baseConfig, 83 | destinations: multipleDestinations, 84 | }; 85 | const { tx } = await createOrdinals(config); 86 | 87 | expect(tx.outputs).toHaveLength(3); // 2 for inscriptions, 1 for change 88 | }); 89 | 90 | test("create ordinals with custom change address", async () => { 91 | const customChangeAddress = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"; 92 | const config = { 93 | ...baseConfig, 94 | changeAddress: customChangeAddress, 95 | }; 96 | const { tx, payChange } = await createOrdinals(config); 97 | expect(payChange).toBeDefined(); 98 | const changeScript = Script.fromBinary(Utils.toArray(payChange?.script, 'base64')) 99 | expect(Utils.toBase58Check(changeScript.chunks[2].data as number[])).toEqual(customChangeAddress); 100 | }); 101 | 102 | // test("create ordinals with signer", async () => { 103 | // const mockSigner = { 104 | // sign: jest.fn().mockResolvedValue("mockedSignature"), 105 | // }; 106 | // const config: CreateOrdinalsConfig = { 107 | // ...baseConfig, 108 | // signer: mockSigner, 109 | // }; 110 | // await createOrdinals(config); 111 | 112 | // expect(mockSigner.sign).toHaveBeenCalled(); 113 | // }); 114 | }); -------------------------------------------------------------------------------- /src/createOrdinals.ts: -------------------------------------------------------------------------------- 1 | import { Transaction, SatoshisPerKilobyte, P2PKH, Script, Utils } from "@bsv/sdk"; 2 | import OrdP2PKH from "./templates/ordP2pkh"; 3 | import type { 4 | Utxo, 5 | CreateOrdinalsConfig, 6 | CreateOrdinalsCollectionConfig, 7 | CreateOrdinalsCollectionItemConfig, 8 | ChangeResult, 9 | } from "./types"; 10 | import { inputFromB64Utxo } from "./utils/utxo"; 11 | import { DEFAULT_SAT_PER_KB } from "./constants"; 12 | import { signData } from "./signData"; 13 | import stringifyMetaData from "./utils/subtypeData"; 14 | 15 | /** 16 | * Creates a transaction with inscription outputs 17 | * @param {CreateOrdinalsConfig | CreateOrdinalsCollectionConfig | CreateOrdinalsCollectionItemConfig} config - Configuration object for creating ordinals 18 | * @param {Utxo[]} config.utxos - Utxos to spend (with base64 encoded scripts) 19 | * @param {Destination[]} config.destinations - Array of destinations with addresses and inscriptions 20 | * @param {PrivateKey} config.paymentPk - Private key to sign utxos 21 | * @param {string} config.changeAddress - Optional. Address to send change to. If not provided, defaults to paymentPk address 22 | * @param {number} config.satsPerKb - Optional. Satoshis per kilobyte for fee calculation. Default is DEFAULT_SAT_PER_KB 23 | * @param {PreMAP} config.metaData - Optional. MAP (Magic Attribute Protocol) metadata to include in inscriptions 24 | * @param {LocalSigner | RemoteSigner} config.signer - Optional. Local or remote signer (used for data signature) 25 | * @param {Payment[]} config.additionalPayments - Optional. Additional payments to include in the transaction 26 | * @returns {Promise} Transaction with inscription outputs 27 | */ 28 | export const createOrdinals = async ( 29 | config: 30 | | CreateOrdinalsConfig 31 | | CreateOrdinalsCollectionConfig 32 | | CreateOrdinalsCollectionItemConfig, 33 | ): Promise => { 34 | const { 35 | utxos, 36 | destinations, 37 | paymentPk, 38 | satsPerKb = DEFAULT_SAT_PER_KB, 39 | metaData, 40 | signer, 41 | additionalPayments = [], 42 | } = config; 43 | 44 | // Warn if creating many inscriptions at once 45 | if (destinations.length > 100) { 46 | console.warn( 47 | "Creating many inscriptions at once can be slow. Consider using multiple transactions instead.", 48 | ); 49 | } 50 | 51 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 52 | let tx = new Transaction(); 53 | 54 | // Outputs 55 | // Add inscription outputs 56 | for (const destination of destinations) { 57 | if (!destination.inscription) { 58 | throw new Error("Inscription is required for all destinations"); 59 | } 60 | 61 | // remove any undefined fields from metadata 62 | if (metaData) { 63 | for(const key of Object.keys(metaData)) { 64 | if (metaData[key] === undefined) { 65 | delete metaData[key]; 66 | } 67 | } 68 | } 69 | 70 | tx.addOutput({ 71 | satoshis: 1, 72 | lockingScript: new OrdP2PKH().lock( 73 | destination.address, 74 | destination.inscription, 75 | stringifyMetaData(metaData), 76 | ), 77 | }); 78 | } 79 | 80 | // Add additional payments if any 81 | for (const p of additionalPayments) { 82 | tx.addOutput({ 83 | satoshis: p.amount, 84 | lockingScript: new P2PKH().lock(p.to), 85 | }); 86 | } 87 | 88 | let payChange: Utxo | undefined; 89 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 90 | if(!changeAddress) { 91 | throw new Error("Either changeAddress or paymentPk is required"); 92 | } 93 | const changeScript = new P2PKH().lock(changeAddress); 94 | const changeOut = { 95 | lockingScript: changeScript, 96 | change: true, 97 | }; 98 | tx.addOutput(changeOut); 99 | 100 | let totalSatsIn = 0n; 101 | const totalSatsOut = tx.outputs.reduce( 102 | (total, out) => total + BigInt(out.satoshis || 0), 103 | 0n, 104 | ); 105 | 106 | if(signer) { 107 | const utxo = utxos.pop() as Utxo 108 | const payKeyToUse = utxo.pk || paymentPk; 109 | if(!payKeyToUse) { 110 | throw new Error("Private key is required to sign the transaction"); 111 | } 112 | tx.addInput(inputFromB64Utxo(utxo, new P2PKH().unlock( 113 | payKeyToUse, 114 | "all", 115 | true, 116 | utxo.satoshis, 117 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 118 | ))); 119 | totalSatsIn += BigInt(utxo.satoshis); 120 | tx = await signData(tx, signer); 121 | } 122 | 123 | let fee = 0; 124 | for (const utxo of utxos) { 125 | const payKeyToUse = utxo.pk || paymentPk; 126 | if(!payKeyToUse) { 127 | throw new Error("Private key is required to sign the transaction"); 128 | } 129 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 130 | break; 131 | } 132 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 133 | payKeyToUse, 134 | "all", 135 | true, 136 | utxo.satoshis, 137 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 138 | )); 139 | 140 | tx.addInput(input); 141 | // stop adding inputs if the total amount is enough 142 | totalSatsIn += BigInt(utxo.satoshis); 143 | fee = await modelOrFee.computeFee(tx); 144 | } 145 | 146 | // make sure we have enough 147 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 148 | throw new Error( 149 | `Not enough funds to create ordinals. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 150 | ); 151 | } 152 | 153 | // Calculate fee 154 | await tx.fee(modelOrFee); 155 | 156 | // Sign the transaction 157 | await tx.sign(); 158 | 159 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 160 | if (payChangeOutIdx !== -1) { 161 | const changeOutput = tx.outputs[payChangeOutIdx]; 162 | payChange = { 163 | satoshis: changeOutput.satoshis as number, 164 | txid: tx.id("hex") as string, 165 | vout: payChangeOutIdx, 166 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 167 | "base64", 168 | ), 169 | }; 170 | } 171 | 172 | if (payChange) { 173 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 174 | payChange.satoshis = changeOutput.satoshis as number; 175 | payChange.txid = tx.id("hex") as string; 176 | } 177 | 178 | return { 179 | tx, 180 | spentOutpoints: utxos.map((utxo) => `${utxo.txid}_${utxo.vout}`), 181 | payChange, 182 | }; 183 | }; 184 | -------------------------------------------------------------------------------- /src/deployBsv21.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { P2PKH, PrivateKey } from "@bsv/sdk"; 3 | import { readFileSync } from 'node:fs'; 4 | import { join } from 'node:path'; 5 | import { deployBsv21Token } from "./deployBsv21"; 6 | import type { DeployBsv21TokenConfig, Distribution, IconInscription, Utxo } from "./types"; 7 | import { ErrorIconProportions, ErrorImageDimensionsUndefined, ErrorOversizedIcon } from "./utils/icon"; 8 | 9 | describe("deployBsv21Token", () => { 10 | const paymentPk = PrivateKey.fromWif("KzwfqdfecMRtpg65j2BeRtixboNR37fSCDr8QbndV6ySEPT4xibW"); 11 | const address = paymentPk.toAddress().toString(); 12 | 13 | const insufficientUtxos: Utxo[] = [{ 14 | satoshis: 6, 15 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 16 | vout: 0, 17 | script: Buffer.from(new P2PKH().lock(address).toHex(), 'hex').toString('base64'), 18 | }]; 19 | 20 | const exactUtxos: Utxo[] = [{ 21 | satoshis: 7, 22 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 23 | vout: 0, 24 | script: Buffer.from(new P2PKH().lock(address).toHex(), 'hex').toString('base64'), 25 | }]; 26 | 27 | const sufficientUtxos: Utxo[] = [{ 28 | satoshis: 30, 29 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 30 | vout: 0, 31 | script: Buffer.from(new P2PKH().lock(address).toHex(), 'hex').toString('base64'), 32 | }]; 33 | 34 | const initialDistribution = { 35 | address: address, 36 | tokens: 10, 37 | } as Distribution 38 | 39 | const symbol = "TEST"; 40 | const svgIcon: IconInscription = { 41 | dataB64: Buffer.from('').toString('base64'), 42 | contentType: "image/svg+xml" 43 | }; 44 | 45 | const baseConfig: DeployBsv21TokenConfig = { 46 | symbol, 47 | icon: svgIcon, 48 | utxos: sufficientUtxos, 49 | initialDistribution, 50 | paymentPk, 51 | destinationAddress: address, 52 | }; 53 | 54 | test("deploy BSV21 token with a sufficient utxo", async () => { 55 | const { tx } = await deployBsv21Token(baseConfig); 56 | 57 | expect(tx).toBeDefined(); 58 | expect(tx.toHex()).toBeTruthy(); 59 | // expect change 60 | expect(tx.outputs.length).toBe(3); 61 | console.log({ txHex: tx.toHex() }); 62 | }); 63 | 64 | test("deploy BSV21 token with an exact utxo", async () => { 65 | const config = { ...baseConfig, utxos: exactUtxos }; 66 | const { tx } = await deployBsv21Token(config); 67 | 68 | expect(tx).toBeDefined(); 69 | expect(tx.toHex()).toBeTruthy(); 70 | // expect no change output 71 | expect(tx.outputs.length).toBe(2); 72 | console.log({ txHex: tx.toHex() }); 73 | }); 74 | 75 | test("deploy BSV21 token with a insufficient utxo", async () => { 76 | const config = { ...baseConfig, utxos: insufficientUtxos }; 77 | // expect this to throw an error 78 | await expect(deployBsv21Token(config)).rejects.toThrow(); 79 | }); 80 | 81 | test("deploy BSV21 token with sufficient utxos", async () => { 82 | const { tx } = await deployBsv21Token(baseConfig); 83 | 84 | expect(tx).toBeDefined(); 85 | expect(tx.toHex()).toBeTruthy(); 86 | console.log({ txHex: tx.toHex() }); 87 | }); 88 | 89 | test("deploy BSV21 token with incorrect image proportion", async () => { 90 | const nonSquareIcon: IconInscription = { 91 | dataB64: Buffer.from('').toString('base64'), 92 | contentType: "image/svg+xml" 93 | }; 94 | const config = { ...baseConfig, icon: nonSquareIcon }; 95 | 96 | await expect(deployBsv21Token(config)).rejects.toThrow(ErrorIconProportions.message); 97 | }); 98 | 99 | test("deploy BSV21 token with oversized image", async () => { 100 | const oversizedIcon: IconInscription = { 101 | dataB64: Buffer.from('').toString('base64'), 102 | contentType: "image/svg+xml" 103 | }; 104 | const config = { ...baseConfig, icon: oversizedIcon }; 105 | 106 | await expect(deployBsv21Token(config)).rejects.toThrow(ErrorOversizedIcon.message); 107 | }); 108 | 109 | test("deploy BSV21 token with SVG missing dimensions", async () => { 110 | const invalidSvgIcon: IconInscription = { 111 | dataB64: Buffer.from('').toString('base64'), 112 | contentType: "image/svg+xml" 113 | }; 114 | const config = { ...baseConfig, icon: invalidSvgIcon }; 115 | 116 | await expect(deployBsv21Token(config)).rejects.toThrow(ErrorImageDimensionsUndefined.message); 117 | }); 118 | 119 | test("deploy BSV21 token with SVG non-numeric dimensions", async () => { 120 | const invalidSvgIcon: IconInscription = { 121 | dataB64: Buffer.from('').toString('base64'), 122 | contentType: "image/svg+xml" 123 | }; 124 | const config = { ...baseConfig, icon: invalidSvgIcon }; 125 | 126 | await expect(deployBsv21Token(config)).rejects.toThrow(ErrorImageDimensionsUndefined.message); 127 | }); 128 | 129 | test("deploy BSV21 token with valid square SVG", async () => { 130 | const validSvgIcon: IconInscription = { 131 | dataB64: Buffer.from('').toString('base64'), 132 | contentType: "image/svg+xml" 133 | }; 134 | const config = { ...baseConfig, icon: validSvgIcon }; 135 | 136 | const { tx } = await deployBsv21Token(config); 137 | expect(tx).toBeDefined(); 138 | expect(tx.toHex()).toBeTruthy(); 139 | }); 140 | 141 | test("deploy BSV21 token with valid PNG", async () => { 142 | const pngBuffer = readFileSync(join(__dirname, 'testdata', 'valid_300x300.png')); 143 | const validPngIcon: IconInscription = { 144 | dataB64: pngBuffer.toString('base64'), 145 | contentType: "image/png" 146 | }; 147 | const config = { ...baseConfig, icon: validPngIcon }; 148 | 149 | const { tx } = await deployBsv21Token(config); 150 | expect(tx).toBeDefined(); 151 | expect(tx.toHex()).toBeTruthy(); 152 | }); 153 | 154 | test("deploy BSV21 token with invalid PNG dimensions", async () => { 155 | const pngBuffer = readFileSync(join(__dirname, 'testdata', 'invalid_400x300.png')); 156 | const invalidPngIcon: IconInscription = { 157 | dataB64: pngBuffer.toString('base64'), 158 | contentType: "image/png" 159 | }; 160 | const config = { ...baseConfig, icon: invalidPngIcon }; 161 | 162 | await expect(deployBsv21Token(config)).rejects.toThrow(ErrorIconProportions.message); 163 | }); 164 | }); -------------------------------------------------------------------------------- /src/deployBsv21.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Transaction, 3 | P2PKH, 4 | SatoshisPerKilobyte, 5 | type TransactionOutput, 6 | Utils, 7 | Script, 8 | } from "@bsv/sdk"; 9 | import type { 10 | ChangeResult, 11 | DeployBsv21TokenConfig, 12 | DeployMintTokenInscription, 13 | Inscription, 14 | Utxo, 15 | } from "./types"; 16 | import { inputFromB64Utxo } from "./utils/utxo"; 17 | import { validIconData, validIconFormat } from "./utils/icon"; 18 | import OrdP2PKH from "./templates/ordP2pkh"; 19 | import { DEFAULT_SAT_PER_KB } from "./constants"; 20 | 21 | /** 22 | * Deploys & Mints a BSV21 token to the given destination address 23 | * @param {DeployBsv21TokenConfig} config - Configuration object for deploying BSV21 token 24 | * @param {string} config.symbol - Token ticker symbol 25 | * @param {number} config.decimals - Number of decimal places to display 26 | * @param {string | IconInscription} config.icon - outpoint (format: txid_vout) or Inscription. If Inscription, must be a valid image type 27 | * @param {Utxo[]} config.utxos - Payment Utxos available to spend. Will only consume what is needed. 28 | * @param {Distribution} config.initialDistribution - Initial distribution with addresses and total supply (not adjusted for decimals, library will add zeros) 29 | * @param {PrivateKey} config.paymentPk - Private key to sign paymentUtxos 30 | * @param {string} config.destinationAddress - Address to deploy token to. 31 | * @param {string} [config.changeAddress] - Optional. Address to send payment change to, if any. If not provided, defaults to paymentPk address 32 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation. Default is DEFAULT_SAT_PER_KB 33 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to include in the transaction 34 | * @returns {Promise} Transaction to deploy BSV 2.1 token 35 | */ 36 | export const deployBsv21Token = async ( 37 | config: DeployBsv21TokenConfig, 38 | ): Promise => { 39 | const { 40 | symbol, 41 | icon, 42 | decimals, 43 | utxos, 44 | initialDistribution, 45 | paymentPk, 46 | destinationAddress, 47 | satsPerKb = DEFAULT_SAT_PER_KB, 48 | additionalPayments = [], 49 | } = config; 50 | 51 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 52 | 53 | const tx = new Transaction(); 54 | 55 | let iconValue: string; 56 | if (typeof icon === "string") { 57 | iconValue = icon; 58 | } else { 59 | const iconError = await validIconData(icon); 60 | if (iconError) { 61 | throw iconError; 62 | } 63 | // add icon inscription to the transaction 64 | const iconScript = new OrdP2PKH().lock(destinationAddress, icon); 65 | const iconOut = { 66 | satoshis: 1, 67 | lockingScript: iconScript, 68 | }; 69 | tx.addOutput(iconOut); 70 | // relative output index of the icon 71 | iconValue = "_0"; 72 | } 73 | 74 | // Ensure the icon format 75 | if (!validIconFormat(iconValue)) { 76 | throw new Error( 77 | "Invalid icon format. Must be either outpoint (format: txid_vout) or relative output index of the icon (format _vout). examples: ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41_0 or _1", 78 | ); 79 | } 80 | 81 | // Outputs 82 | const tsatAmt = decimals ? BigInt(initialDistribution.tokens) * 10n ** BigInt(decimals) : BigInt(initialDistribution.tokens); 83 | const fileData: DeployMintTokenInscription = { 84 | p: "bsv-20", 85 | op: "deploy+mint", 86 | sym: symbol, 87 | icon: iconValue, 88 | amt: tsatAmt.toString(), 89 | }; 90 | 91 | if (decimals) { 92 | fileData.dec = decimals.toString(); 93 | } 94 | 95 | const b64File = Buffer.from(JSON.stringify(fileData)).toString("base64"); 96 | const sendTxOut = { 97 | satoshis: 1, 98 | lockingScript: new OrdP2PKH().lock(destinationAddress, { 99 | dataB64: b64File, 100 | contentType: "application/bsv-20", 101 | } as Inscription), 102 | }; 103 | tx.addOutput(sendTxOut); 104 | 105 | // Additional payments 106 | for (const payment of additionalPayments) { 107 | const sendTxOut: TransactionOutput = { 108 | satoshis: payment.amount, 109 | lockingScript: new P2PKH().lock(payment.to), 110 | }; 111 | tx.addOutput(sendTxOut); 112 | } 113 | 114 | // Inputs 115 | let totalSatsIn = 0n; 116 | const totalSatsOut = tx.outputs.reduce( 117 | (total, out) => total + BigInt(out.satoshis || 0), 118 | 0n, 119 | ); 120 | let fee = 0; 121 | for (const utxo of utxos) { 122 | const payKeyToUse = utxo.pk || paymentPk; 123 | if (!payKeyToUse) { 124 | throw new Error("Private key is required to sign the payment"); 125 | } 126 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 127 | payKeyToUse, 128 | "all", 129 | true, 130 | utxo.satoshis, 131 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 132 | )); 133 | tx.addInput(input); 134 | // stop adding inputs if the total amount is enough 135 | totalSatsIn += BigInt(utxo.satoshis); 136 | fee = await modelOrFee.computeFee(tx); 137 | 138 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 139 | break; 140 | } 141 | } 142 | 143 | // make sure we have enough 144 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 145 | throw new Error( 146 | `Not enough funds to deploy token. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 147 | ); 148 | } 149 | 150 | // if we need to send change, add it to the outputs 151 | let payChange: Utxo | undefined; 152 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 153 | if(!changeAddress) { 154 | throw new Error("Either changeAddress or paymentPk is required"); 155 | } 156 | 157 | const changeScript = new P2PKH().lock(changeAddress); 158 | const changeOut = { 159 | lockingScript: changeScript, 160 | change: true, 161 | }; 162 | tx.addOutput(changeOut); 163 | 164 | // estimate the cost of the transaction and assign change value 165 | await tx.fee(modelOrFee); 166 | 167 | // Sign the transaction 168 | await tx.sign(); 169 | 170 | // check for change 171 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 172 | if (payChangeOutIdx !== -1) { 173 | const changeOutput = tx.outputs[payChangeOutIdx]; 174 | payChange = { 175 | satoshis: changeOutput.satoshis as number, 176 | txid: tx.id("hex") as string, 177 | vout: payChangeOutIdx, 178 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 179 | "base64", 180 | ), 181 | }; 182 | } 183 | 184 | return { 185 | tx, 186 | spentOutpoints: tx.inputs.map( 187 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 188 | ), 189 | payChange, 190 | }; 191 | }; 192 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { PrivateKey, P2PKH} from "@bsv/sdk"; 3 | import { createOrdinals, sendOrdinals } from "."; 4 | import OrdP2PKH from "./templates/ordP2pkh"; 5 | import type { Destination } from "./types"; 6 | import type { SendOrdinalsConfig } from "./types"; 7 | import type { CreateOrdinalsConfig } from "./types"; 8 | 9 | test("test build inscription", () => { 10 | const dataB64 = "# Hello World!"; 11 | const insc = new OrdP2PKH().lock("18qHtzaMU5PxJ2Yfuw8yJvDCbULrv1Xsdx", { 12 | dataB64, 13 | contentType: "text/markdown", 14 | }); 15 | expect(insc.toASM()).toBe( 16 | "OP_0 OP_IF 6f7264 OP_1 746578742f6d61726b646f776e OP_0 1de965a16a2b95 OP_ENDIF OP_DUP OP_HASH160 55eaf379d85b0ab99cf5bbfc38a583eafee11683 OP_EQUALVERIFY OP_CHECKSIG", 17 | ); 18 | }); 19 | 20 | test("test build inscription w metadata", () => { 21 | const dataB64 = "# Hello world!"; 22 | const insc = new OrdP2PKH().lock( 23 | "18qHtzaMU5PxJ2Yfuw8yJvDCbULrv1Xsdx", 24 | { dataB64, contentType: "text/markdown" }, 25 | { 26 | app: "js-1sat-ord-test", 27 | type: "test", 28 | }, 29 | ); 30 | expect(insc.toASM()).toBe( 31 | "OP_0 OP_IF 6f7264 OP_1 746578742f6d61726b646f776e OP_0 1de965a30a2b95 OP_ENDIF OP_DUP OP_HASH160 55eaf379d85b0ab99cf5bbfc38a583eafee11683 OP_EQUALVERIFY OP_CHECKSIG OP_RETURN 3150755161374b36324d694b43747373534c4b79316b683536575755374d74555235 534554 617070 6a732d317361742d6f72642d74657374 74797065 74657374", 32 | ); 33 | }); 34 | 35 | test("create and send ordinal inscription", async () => { 36 | const paymentPk = PrivateKey.fromWif( 37 | "KzwfqdfecMRtpg65j2BeRtixboNR37fSCDr8QbndV6ySEPT4xibW", 38 | ); 39 | const changeAddress = paymentPk.toAddress(); 40 | let utxos = [ 41 | { 42 | satoshis: 100000, 43 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 44 | vout: 0, 45 | script: Buffer.from( 46 | new P2PKH().lock(changeAddress).toHex(), 47 | "hex", 48 | ).toString("base64"), 49 | }, 50 | ]; 51 | 52 | let destinations: Destination[] = [ 53 | { 54 | address: changeAddress, 55 | inscription: { 56 | dataB64: Buffer.from("hello world").toString("base64"), 57 | contentType: "text/plain", 58 | }, 59 | }, 60 | ]; 61 | 62 | const createOrdinalsConfig: CreateOrdinalsConfig = { 63 | utxos, 64 | destinations, 65 | paymentPk, 66 | }; 67 | const { tx } = await createOrdinals(createOrdinalsConfig); 68 | console.log({ createOrdinal: tx.toHex() }); 69 | 70 | utxos = [ 71 | { 72 | satoshis: tx.outputs[1].satoshis || 1, 73 | txid: tx.id("hex"), 74 | vout: 1, 75 | script: Buffer.from(tx.outputs[1].lockingScript.toHex(), "hex").toString( 76 | "base64", 77 | ), 78 | }, 79 | ]; 80 | 81 | let ordinals = [ 82 | { 83 | satoshis: tx.outputs[0].satoshis || 1, 84 | txid: tx.id("hex"), 85 | vout: 0, 86 | script: Buffer.from(tx.outputs[0].lockingScript.toHex(), "hex").toString( 87 | "base64", 88 | ), 89 | }, 90 | ]; 91 | 92 | destinations = [ 93 | { 94 | address: changeAddress, 95 | inscription: { 96 | dataB64: Buffer.from("reinscription!").toString("base64"), 97 | contentType: "text/plain", 98 | }, 99 | }, 100 | ]; 101 | 102 | const { tx: tx2 } = await sendOrdinals({ 103 | paymentUtxos: utxos, 104 | ordinals, 105 | paymentPk, 106 | destinations, 107 | ordPk: paymentPk, 108 | }); 109 | console.log({ sendCreatedOrdinal: tx2.toHex() }); 110 | 111 | utxos = [ 112 | { 113 | satoshis: tx2.outputs[1].satoshis || 1, 114 | txid: tx2.id("hex"), 115 | vout: 1, 116 | script: Buffer.from(tx.outputs[1].lockingScript.toHex(), "hex").toString( 117 | "base64", 118 | ), 119 | }, 120 | ]; 121 | 122 | ordinals = [ 123 | { 124 | satoshis: tx2.outputs[0].satoshis || 1, 125 | txid: tx2.id("hex"), 126 | vout: 0, 127 | script: Buffer.from(tx2.outputs[0].lockingScript.toHex(), "hex").toString( 128 | "base64", 129 | ), 130 | }, 131 | ]; 132 | 133 | destinations = [ 134 | { 135 | address: changeAddress, 136 | }, 137 | ]; 138 | 139 | const sendConfig: SendOrdinalsConfig = { 140 | paymentUtxos: utxos, 141 | ordinals, 142 | paymentPk, 143 | ordPk: paymentPk, 144 | destinations, 145 | }; 146 | const { tx: tx3 } = await sendOrdinals(sendConfig); 147 | console.log({ sendSentOrdinal: tx3.toHex() }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createOrdinals } from "./createOrdinals"; 2 | import { sendOrdinals } from "./sendOrdinals"; 3 | import { sendUtxos } from "./sendUtxos"; 4 | import { transferOrdTokens } from "./transferOrdinals"; 5 | import { 6 | fetchNftUtxos, 7 | fetchPayUtxos, 8 | fetchTokenUtxos, 9 | selectTokenUtxos, 10 | } from "./utils/utxo"; 11 | import { validateSubTypeData } from "./validate"; 12 | import OrdP2PKH, { applyInscription } from "./templates/ordP2pkh"; 13 | import OrdLock from "./templates/ordLock"; 14 | import stringifyMetaData from "./utils/subtypeData"; 15 | import { createOrdListings, createOrdTokenListings } from "./createListings"; 16 | import { cancelOrdListings, cancelOrdTokenListings } from "./cancelListings"; 17 | import { 18 | purchaseOrdListing, 19 | purchaseOrdTokenListing, 20 | } from "./purchaseOrdListing"; 21 | import { deployBsv21Token } from "./deployBsv21"; 22 | import { burnOrdinals } from "./burnOrdinals"; 23 | import OneSatBroadcaster, { oneSatBroadcaster } from "./utils/broadcast"; 24 | 25 | export * from "./types"; 26 | 27 | export { 28 | createOrdinals, 29 | sendOrdinals, 30 | sendUtxos, 31 | burnOrdinals, 32 | transferOrdTokens, 33 | deployBsv21Token, 34 | fetchPayUtxos, 35 | fetchNftUtxos, 36 | fetchTokenUtxos, 37 | selectTokenUtxos, 38 | validateSubTypeData, 39 | OrdP2PKH, 40 | OrdLock, 41 | stringifyMetaData, 42 | createOrdListings, 43 | cancelOrdListings, 44 | purchaseOrdListing, 45 | purchaseOrdTokenListing, 46 | cancelOrdTokenListings, 47 | createOrdTokenListings, 48 | oneSatBroadcaster, 49 | OneSatBroadcaster, 50 | applyInscription, 51 | }; 52 | -------------------------------------------------------------------------------- /src/purchaseOrdListing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { PrivateKey, Transaction } from "@bsv/sdk"; 3 | import { purchaseOrdTokenListing } from "./purchaseOrdListing"; 4 | import { TokenType, type PurchaseOrdTokenListingConfig, type TokenUtxo, type Utxo } from "./types"; 5 | 6 | describe("purchaseOrdListings", () => { 7 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 8 | const address = paymentPk.toAddress().toString(); 9 | 10 | const utxos: Utxo[] = [{ 11 | satoshis: 9910000, 12 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 13 | vout: 0, 14 | script: "base64EncodedScript", 15 | }]; 16 | 17 | const listingUtxo: TokenUtxo = { 18 | satoshis: 1, 19 | txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 20 | vout: 0, 21 | script: "base64EncodedScript", 22 | amt: "1000", 23 | id: "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0", 24 | payout: "wPs5AAAAAAAZdqkUF/HQ6ktHp6Ab8txx3xBGIs103PyIrA==", 25 | price: 3800000, 26 | isListing: true, 27 | }; 28 | 29 | const baseConfig: PurchaseOrdTokenListingConfig = { 30 | protocol: TokenType.BSV20, 31 | tokenID: "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0", 32 | utxos, 33 | paymentPk, 34 | listingUtxo, 35 | ordAddress: address, 36 | }; 37 | 38 | test("purchase ord listing with sufficient funds", async () => { 39 | const { tx, spentOutpoints, payChange } = await purchaseOrdTokenListing(baseConfig); 40 | 41 | expect(tx).toBeInstanceOf(Transaction); 42 | expect(spentOutpoints).toHaveLength(2); // 1 payment utxo + 1 listing utxo 43 | expect(payChange).toBeDefined(); 44 | }); 45 | 46 | test("purchase ord listing with BSV21 protocol", async () => { 47 | const bsv21Config = { 48 | ...baseConfig, 49 | protocol: TokenType.BSV21, 50 | }; 51 | const { tx } = await purchaseOrdTokenListing(bsv21Config); 52 | 53 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("bsv-20").toString('hex')); 54 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("id").toString('hex')); 55 | }); 56 | 57 | test("purchase ord listings with additional payments", async () => { 58 | const configWithPayments = { 59 | ...baseConfig, 60 | additionalPayments: [{ to: address, amount: 1000 }], 61 | }; 62 | const { tx } = await purchaseOrdTokenListing(configWithPayments); 63 | 64 | expect(tx.outputs).toHaveLength(4); // 1 for ordinal transfer, 1 for payment, 1 additional payment, 1 for change 65 | }); 66 | 67 | test("purchase ord listings with insufficient funds", async () => { 68 | const insufficientConfig = { 69 | ...baseConfig, 70 | utxos: [{ ...utxos[0], satoshis: 1 }], 71 | }; 72 | await expect(purchaseOrdTokenListing(insufficientConfig)).rejects.toThrow("Not enough funds"); 73 | }); 74 | }); 75 | 76 | describe("purchaseOrdTokenListing", () => { 77 | const paymentPk = PrivateKey.fromWif("KwE2RgUthyfEZbzrS3EEgSRVr1NodBc9B3vPww6oSGChDuWS6Heb"); 78 | const address = paymentPk.toAddress().toString(); 79 | 80 | const utxos: Utxo[] = [{ 81 | satoshis: 9910000, 82 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 83 | vout: 0, 84 | script: "base64EncodedScript", 85 | }]; 86 | 87 | const listingUtxo: TokenUtxo = { 88 | satoshis: 1, 89 | txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 90 | vout: 0, 91 | script: "base64EncodedScript", 92 | amt: "1000", 93 | id: "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0", 94 | payout: "wPs5AAAAAAAZdqkUF/HQ6ktHp6Ab8txx3xBGIs103PyIrA==", 95 | price: 3800000, 96 | isListing: true, 97 | }; 98 | 99 | const baseConfig: PurchaseOrdTokenListingConfig = { 100 | protocol: TokenType.BSV20, 101 | tokenID: "e6d40ba206340aa94ed40fe1a8adcd722c08c9438b2c1dd16b4527d561e848a2_0", 102 | utxos, 103 | paymentPk, 104 | listingUtxo, 105 | ordAddress: address, 106 | }; 107 | 108 | test("purchase ord token listing with sufficient funds", async () => { 109 | const { tx, spentOutpoints, payChange } = await purchaseOrdTokenListing(baseConfig); 110 | 111 | expect(tx).toBeInstanceOf(Transaction); 112 | expect(spentOutpoints).toHaveLength(2); // 1 listing, 1 payment utxo 113 | expect(payChange).toBeDefined(); 114 | }); 115 | 116 | test("purchase ord token listing with BSV21 protocol", async () => { 117 | const bsv21Config = { 118 | ...baseConfig, 119 | protocol: TokenType.BSV21, 120 | }; 121 | const { tx } = await purchaseOrdTokenListing(bsv21Config); 122 | 123 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("bsv-20").toString('hex')); 124 | expect(tx.outputs[0].lockingScript.toHex()).toContain(Buffer.from("id").toString('hex')); 125 | }); 126 | 127 | test("purchase ord token listing with additional payments", async () => { 128 | const configWithPayments = { 129 | ...baseConfig, 130 | additionalPayments: [{ to: address, amount: 1000 }], 131 | }; 132 | const { tx } = await purchaseOrdTokenListing(configWithPayments); 133 | console.log({ txHex: tx.toHex() }); 134 | expect(tx.outputs).toHaveLength(4); // 1 for token transfer, 1 for payment, 1 additional payment, 1 for change 135 | }); 136 | 137 | test("purchase ord token listing with insufficient funds", async () => { 138 | const insufficientConfig = { 139 | ...baseConfig, 140 | utxos: [{ ...utxos[0], satoshis: 1 }], 141 | }; 142 | await expect(purchaseOrdTokenListing(insufficientConfig)).rejects.toThrow("Not enough funds"); 143 | }); 144 | }); -------------------------------------------------------------------------------- /src/purchaseOrdListing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LockingScript, 3 | P2PKH, 4 | SatoshisPerKilobyte, 5 | Script, 6 | Transaction, 7 | Utils, 8 | } from "@bsv/sdk"; 9 | import { DEFAULT_SAT_PER_KB } from "./constants"; 10 | import OrdLock from "./templates/ordLock"; 11 | import OrdP2PKH from "./templates/ordP2pkh"; 12 | import { 13 | type ChangeResult, 14 | RoytaltyType, 15 | TokenType, 16 | type PurchaseOrdListingConfig, 17 | type PurchaseOrdTokenListingConfig, 18 | type TransferBSV20Inscription, 19 | type TransferBSV21Inscription, 20 | type TransferTokenInscription, 21 | type Utxo, 22 | MAP, 23 | } from "./types"; 24 | import { resolvePaymail } from "./utils/paymail"; 25 | import { inputFromB64Utxo } from "./utils/utxo"; 26 | 27 | /** 28 | * Purchase a listing 29 | * @param {PurchaseOrdListingConfig} config - Configuration object for purchasing a listing 30 | * @param {Utxo[]} config.utxos - Utxos to spend (with base64 encoded scripts) 31 | * @param {PrivateKey} config.paymentPk - Private key to sign payment inputs 32 | * @param {ExistingListing} config.listing - Listing to purchase 33 | * @param {string} config.ordAddress - Address to send the ordinal to 34 | * @param {string} [config.changeAddress] - Optional. Address to send change to 35 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation 36 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to make 37 | * @param {Royalty[]} [config.royalties] - Optional. Royalties to pay 38 | * @param {MAP} [config.metaData] - Optional. MAP (Magic Attribute Protocol) metadata to include on purchased output 39 | * @returns {Promise} Transaction, spent outpoints, change utxo 40 | */ 41 | export const purchaseOrdListing = async ( 42 | config: PurchaseOrdListingConfig, 43 | ): Promise => { 44 | const { 45 | utxos, 46 | paymentPk, 47 | listing, 48 | ordAddress, 49 | additionalPayments = [], 50 | satsPerKb = DEFAULT_SAT_PER_KB, 51 | royalties = [], 52 | metaData, 53 | } = config; 54 | 55 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 56 | const tx = new Transaction(); 57 | 58 | // Inputs 59 | // Add the locked ordinal we're purchasing 60 | tx.addInput( 61 | inputFromB64Utxo( 62 | listing.listingUtxo, 63 | new OrdLock().purchaseListing( 64 | 1, 65 | Script.fromBinary(Utils.toArray(listing.listingUtxo.script, "base64")), 66 | ), 67 | ), 68 | ); 69 | 70 | // Outputs 71 | // Add the purchased output 72 | tx.addOutput({ 73 | satoshis: 1, 74 | lockingScript: new OrdP2PKH().lock(ordAddress, undefined, metaData), 75 | }); 76 | 77 | // add the payment output 78 | const reader = new Utils.Reader(Utils.toArray(listing.payout, "base64")); 79 | const satoshis = reader.readUInt64LEBn().toNumber(); 80 | const scriptLength = reader.readVarIntNum(); 81 | const scriptBin = reader.read(scriptLength); 82 | const lockingScript = LockingScript.fromBinary(scriptBin); 83 | tx.addOutput({ 84 | satoshis, 85 | lockingScript, 86 | }); 87 | 88 | // Add additional payments if any 89 | for (const p of additionalPayments) { 90 | tx.addOutput({ 91 | satoshis: p.amount, 92 | lockingScript: new P2PKH().lock(p.to), 93 | }); 94 | } 95 | 96 | // Add any royalties 97 | for (const r of royalties) { 98 | let lockingScript: LockingScript | undefined; 99 | const royaltySats = Math.floor(Number(r.percentage) * satoshis); 100 | 101 | switch (r.type as RoytaltyType) { 102 | case RoytaltyType.Paymail: 103 | // resolve paymail address 104 | lockingScript = await resolvePaymail(r.destination, royaltySats); 105 | break; 106 | case RoytaltyType.Script: 107 | lockingScript = Script.fromBinary( 108 | Utils.toArray(r.destination, "base64"), 109 | ); 110 | break; 111 | case RoytaltyType.Address: 112 | lockingScript = new P2PKH().lock(r.destination); 113 | break; 114 | default: 115 | throw new Error("Invalid royalty type"); 116 | } 117 | if (!lockingScript) { 118 | throw new Error("Invalid royalty destination"); 119 | } 120 | tx.addOutput({ 121 | satoshis: royaltySats, 122 | lockingScript, 123 | }); 124 | } 125 | 126 | // add change to the outputs 127 | let payChange: Utxo | undefined; 128 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 129 | if (!changeAddress) { 130 | throw new Error("Either changeAddress or paymentPk is required"); 131 | } 132 | const changeScript = new P2PKH().lock(changeAddress); 133 | const changeOut = { 134 | lockingScript: changeScript, 135 | change: true, 136 | }; 137 | tx.addOutput(changeOut); 138 | 139 | let totalSatsIn = 0n; 140 | const totalSatsOut = tx.outputs.reduce( 141 | (total, out) => total + BigInt(out.satoshis || 0), 142 | 0n, 143 | ); 144 | let fee = 0; 145 | for (const utxo of utxos) { 146 | const payKeyToUse = utxo.pk || paymentPk; 147 | if(!payKeyToUse) { 148 | throw new Error("Private key is required to sign the payment"); 149 | } 150 | const input = inputFromB64Utxo( 151 | utxo, 152 | new P2PKH().unlock( 153 | payKeyToUse, 154 | "all", 155 | true, 156 | utxo.satoshis, 157 | Script.fromBinary(Utils.toArray(utxo.script, "base64")), 158 | ), 159 | ); 160 | 161 | tx.addInput(input); 162 | // stop adding inputs if the total amount is enough 163 | totalSatsIn += BigInt(utxo.satoshis); 164 | fee = await modelOrFee.computeFee(tx); 165 | 166 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 167 | break; 168 | } 169 | } 170 | 171 | // make sure we have enough 172 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 173 | throw new Error( 174 | `Not enough funds to purchase ordinal listing. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 175 | ); 176 | } 177 | 178 | // estimate the cost of the transaction and assign change value 179 | await tx.fee(modelOrFee); 180 | 181 | // Sign the transaction 182 | await tx.sign(); 183 | 184 | // check for change 185 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 186 | if (payChangeOutIdx !== -1) { 187 | const changeOutput = tx.outputs[payChangeOutIdx]; 188 | payChange = { 189 | satoshis: changeOutput.satoshis as number, 190 | txid: tx.id("hex") as string, 191 | vout: payChangeOutIdx, 192 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 193 | "base64", 194 | ), 195 | }; 196 | } 197 | 198 | if (payChange) { 199 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 200 | payChange.satoshis = changeOutput.satoshis as number; 201 | payChange.txid = tx.id("hex") as string; 202 | } 203 | 204 | return { 205 | tx, 206 | spentOutpoints: tx.inputs.map( 207 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 208 | ), 209 | payChange, 210 | }; 211 | }; 212 | 213 | /** 214 | * 215 | * @param {PurchaseOrdTokenListingConfig} config - Configuration object for purchasing a token listing 216 | * @param {TokenType} config.protocol - Token protocol 217 | * @param {string} config.tokenID - Token ID 218 | * @param {Utxo[]} config.utxos - Utxos to spend (with base64 encoded scripts) 219 | * @param {PrivateKey} config.paymentPk - Private key to sign payment inputs 220 | * @param {Utxo} config.listingUtxo - Listing UTXO 221 | * @param {string} config.ordAddress - Address to send the ordinal to 222 | * @param {string} [config.changeAddress] - Optional. Address to send change to 223 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation 224 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to make 225 | * @param {MAP} [config.metaData] - Optional. MAP (Magic Attribute Protocol) metadata to include on the purchased transfer inscription output 226 | * @returns {Promise} Transaction, spent outpoints, change utxo 227 | */ 228 | export const purchaseOrdTokenListing = async ( 229 | config: PurchaseOrdTokenListingConfig, 230 | ): Promise => { 231 | const { 232 | protocol, 233 | tokenID, 234 | utxos, 235 | paymentPk, 236 | listingUtxo, 237 | ordAddress, 238 | satsPerKb = DEFAULT_SAT_PER_KB, 239 | additionalPayments = [], 240 | metaData, 241 | } = config; 242 | 243 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 244 | const tx = new Transaction(); 245 | 246 | // Inputs 247 | // Add the locked ordinal we're purchasing 248 | tx.addInput( 249 | inputFromB64Utxo( 250 | listingUtxo, 251 | new OrdLock().purchaseListing( 252 | 1, 253 | Script.fromBinary(Utils.toArray(listingUtxo.script, "base64")), 254 | ), 255 | ), 256 | ); 257 | 258 | // Outputs 259 | const transferInscription: TransferTokenInscription = { 260 | p: "bsv-20", 261 | op: "transfer", 262 | amt: listingUtxo.amt, 263 | }; 264 | let inscription: TransferBSV20Inscription | TransferBSV21Inscription; 265 | if (protocol === TokenType.BSV20) { 266 | inscription = { 267 | ...transferInscription, 268 | tick: tokenID, 269 | } as TransferBSV20Inscription; 270 | } else if (protocol === TokenType.BSV21) { 271 | inscription = { 272 | ...transferInscription, 273 | id: tokenID, 274 | } as TransferBSV21Inscription; 275 | } else { 276 | throw new Error("Invalid protocol"); 277 | } 278 | const dataB64 = Buffer.from(JSON.stringify(inscription)).toString("base64"); 279 | 280 | // Add the purchased output 281 | tx.addOutput({ 282 | satoshis: 1, 283 | lockingScript: new OrdP2PKH().lock( 284 | ordAddress, 285 | { 286 | dataB64, 287 | contentType: "application/bsv-20", 288 | }, 289 | metaData, 290 | ), 291 | }); 292 | 293 | if (!listingUtxo.payout) { 294 | throw new Error("Listing UTXO does not have a payout script"); 295 | } 296 | 297 | // Add the payment output 298 | const reader = new Utils.Reader(Utils.toArray(listingUtxo.payout, "base64")); 299 | const satoshis = reader.readUInt64LEBn().toNumber(); 300 | const scriptLength = reader.readVarIntNum(); 301 | const scriptBin = reader.read(scriptLength); 302 | const lockingScript = LockingScript.fromBinary(scriptBin); 303 | tx.addOutput({ 304 | satoshis, 305 | lockingScript, 306 | }); 307 | 308 | // Add additional payments if any 309 | for (const p of additionalPayments) { 310 | tx.addOutput({ 311 | satoshis: p.amount, 312 | lockingScript: new P2PKH().lock(p.to), 313 | }); 314 | } 315 | 316 | // add change to the outputs 317 | let payChange: Utxo | undefined; 318 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 319 | if (!changeAddress) { 320 | throw new Error("Either changeAddress or paymentPk is required"); 321 | } 322 | const changeScript = new P2PKH().lock(changeAddress); 323 | const changeOut = { 324 | lockingScript: changeScript, 325 | change: true, 326 | }; 327 | tx.addOutput(changeOut); 328 | 329 | let totalSatsIn = 0n; 330 | const totalSatsOut = tx.outputs.reduce( 331 | (total, out) => total + BigInt(out.satoshis || 0), 332 | 0n, 333 | ); 334 | let fee = 0; 335 | for (const utxo of utxos) { 336 | const payKeyToUse = utxo.pk || paymentPk; 337 | if (!payKeyToUse) { 338 | throw new Error("Private key is required to sign the payment"); 339 | } 340 | const input = inputFromB64Utxo( 341 | utxo, 342 | new P2PKH().unlock( 343 | payKeyToUse, 344 | "all", 345 | true, 346 | utxo.satoshis, 347 | Script.fromBinary(Utils.toArray(utxo.script, "base64")), 348 | ), 349 | ); 350 | 351 | tx.addInput(input); 352 | // stop adding inputs if the total amount is enough 353 | totalSatsIn += BigInt(utxo.satoshis); 354 | fee = await modelOrFee.computeFee(tx); 355 | 356 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 357 | break; 358 | } 359 | } 360 | 361 | // make sure we have enough 362 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 363 | throw new Error( 364 | `Not enough funds to purchase token listing. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 365 | ); 366 | } 367 | 368 | // estimate the cost of the transaction and assign change value 369 | await tx.fee(modelOrFee); 370 | 371 | // Sign the transaction 372 | await tx.sign(); 373 | 374 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 375 | if (payChangeOutIdx !== -1) { 376 | const changeOutput = tx.outputs[payChangeOutIdx]; 377 | payChange = { 378 | satoshis: changeOutput.satoshis as number, 379 | txid: tx.id("hex") as string, 380 | vout: payChangeOutIdx, 381 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 382 | "base64", 383 | ), 384 | }; 385 | } 386 | 387 | if (payChange) { 388 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 389 | payChange.satoshis = changeOutput.satoshis as number; 390 | payChange.txid = tx.id("hex") as string; 391 | } 392 | 393 | return { 394 | tx, 395 | spentOutpoints: tx.inputs.map( 396 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 397 | ), 398 | payChange, 399 | }; 400 | }; 401 | -------------------------------------------------------------------------------- /src/sendOrdinals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Transaction, 3 | SatoshisPerKilobyte, 4 | P2PKH, 5 | Script, 6 | Utils, 7 | } from "@bsv/sdk"; 8 | import { DEFAULT_SAT_PER_KB } from "./constants"; 9 | import OrdP2PKH from "./templates/ordP2pkh"; 10 | import type { SendOrdinalsConfig, Utxo, ChangeResult } from "./types"; 11 | import { inputFromB64Utxo } from "./utils/utxo"; 12 | import { signData } from "./signData"; 13 | import stringifyMetaData from "./utils/subtypeData"; 14 | 15 | /** 16 | * Sends ordinals to the given destinations 17 | * @param {SendOrdinalsConfig} config - Configuration object for sending ordinals 18 | * @param {Utxo[]} config.paymentUtxos - Utxos to spend (with base64 encoded scripts) 19 | * @param {Utxo[]} config.ordinals - Utxos to spend (with base64 encoded scripts) 20 | * @param {PrivateKey} config.paymentPk - Private key to sign paymentUtxos 21 | * @param {PrivateKey} config.ordPk - Private key to sign ordinals 22 | * @param {Destination[]} config.destinations - Array of destinations with addresses and inscriptions 23 | * @param {string} [config.changeAddress] - Optional. Address to send change to, if any. If not provided, defaults to paymentPk address 24 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation. Default is DEFAULT_SAT_PER_KB 25 | * @param {PreMAP} [config.metaData] - Optional. MAP (Magic Attribute Protocol) metadata to include in inscriptions 26 | * @param {LocalSigner | RemoteSigner} [config.signer] - Optional. Signer object to sign the transaction 27 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to include in the transaction 28 | * @param {boolean} [config.enforceUniformSend] - Optional. Default: true. Enforce that the number of destinations matches the number of ordinals being sent. Sending ordinals requires a 1:1 mapping of destinations to ordinals. This is only used for sub-protocols like BSV21 that manage tokens without sending the inscriptions directly. 29 | * @returns {Promise} Transaction, spent outpoints, and change utxo 30 | */ 31 | export const sendOrdinals = async ( 32 | config: SendOrdinalsConfig, 33 | ): Promise => { 34 | if (!config.satsPerKb) { 35 | config.satsPerKb = DEFAULT_SAT_PER_KB; 36 | } 37 | if (!config.additionalPayments) { 38 | config.additionalPayments = []; 39 | } 40 | if (config.enforceUniformSend === undefined) { 41 | config.enforceUniformSend = true; 42 | } 43 | 44 | const {ordPk, paymentPk} = config; 45 | 46 | const modelOrFee = new SatoshisPerKilobyte(config.satsPerKb); 47 | let tx = new Transaction(); 48 | const spentOutpoints: string[] = []; 49 | 50 | // Inputs 51 | // Add ordinal inputs 52 | for (const ordUtxo of config.ordinals) { 53 | const ordKeyToUse = ordUtxo.pk || ordPk; 54 | if (!ordKeyToUse) { 55 | throw new Error("Private key is required to sign the ordinal"); 56 | } 57 | if (ordUtxo.satoshis !== 1) { 58 | throw new Error("1Sat Ordinal utxos must have exactly 1 satoshi"); 59 | } 60 | 61 | const input = inputFromB64Utxo( 62 | ordUtxo, 63 | new OrdP2PKH().unlock( 64 | ordKeyToUse, 65 | "all", 66 | true, 67 | ordUtxo.satoshis, 68 | Script.fromBinary(Utils.toArray(ordUtxo.script, 'base64')) 69 | ), 70 | ); 71 | spentOutpoints.push(`${ordUtxo.txid}_${ordUtxo.vout}`); 72 | tx.addInput(input); 73 | } 74 | 75 | // Outputs 76 | // check that ordinals coming in matches ordinals going out if supplied 77 | if ( 78 | config.enforceUniformSend && 79 | config.destinations.length !== config.ordinals.length 80 | ) { 81 | throw new Error( 82 | "Number of destinations must match number of ordinals being sent", 83 | ); 84 | } 85 | 86 | // Add ordinal outputs 87 | for (const destination of config.destinations) { 88 | let s: Script; 89 | if ( 90 | destination.inscription?.dataB64 && 91 | destination.inscription?.contentType 92 | ) { 93 | s = new OrdP2PKH().lock( 94 | destination.address, 95 | destination.inscription, 96 | stringifyMetaData(config.metaData), 97 | ); 98 | } else { 99 | s = new P2PKH().lock(destination.address); 100 | } 101 | 102 | tx.addOutput({ 103 | satoshis: 1, 104 | lockingScript: s, 105 | }); 106 | } 107 | 108 | 109 | // Add additional payments if any 110 | for (const p of config.additionalPayments) { 111 | tx.addOutput({ 112 | satoshis: p.amount, 113 | lockingScript: new P2PKH().lock(p.to), 114 | }); 115 | } 116 | 117 | // add change to the outputs 118 | let payChange: Utxo | undefined; 119 | 120 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 121 | if(!changeAddress) { 122 | throw new Error("Either changeAddress or paymentPk is required"); 123 | } 124 | const changeScript = new P2PKH().lock(changeAddress); 125 | const changeOut = { 126 | lockingScript: changeScript, 127 | change: true, 128 | }; 129 | tx.addOutput(changeOut); 130 | 131 | // Inputs 132 | let totalSatsIn = 0n; 133 | const totalSatsOut = tx.outputs.reduce( 134 | (total, out) => total + BigInt(out.satoshis || 0), 135 | 0n, 136 | ); 137 | let fee = 0; 138 | for (const utxo of config.paymentUtxos) { 139 | const payKeyToUse = utxo.pk || paymentPk; 140 | if (!payKeyToUse) { 141 | throw new Error("Private key is required to sign the payment"); 142 | } 143 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 144 | payKeyToUse, 145 | "all", 146 | true, 147 | utxo.satoshis, 148 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 149 | )); 150 | spentOutpoints.push(`${utxo.txid}_${utxo.vout}`); 151 | 152 | tx.addInput(input); 153 | // stop adding inputs if the total amount is enough 154 | totalSatsIn += BigInt(utxo.satoshis); 155 | fee = await modelOrFee.computeFee(tx); 156 | 157 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 158 | break; 159 | } 160 | } 161 | 162 | if (totalSatsIn < totalSatsOut) { 163 | throw new Error("Not enough ordinals to send"); 164 | } 165 | 166 | if (config.signer) { 167 | tx = await signData(tx, config.signer); 168 | } 169 | 170 | // Calculate fee 171 | await tx.fee(modelOrFee); 172 | 173 | // Sign the transaction 174 | await tx.sign(); 175 | 176 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 177 | if (payChangeOutIdx !== -1) { 178 | const changeOutput = tx.outputs[payChangeOutIdx]; 179 | payChange = { 180 | satoshis: changeOutput.satoshis as number, 181 | txid: tx.id("hex") as string, 182 | vout: payChangeOutIdx, 183 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 184 | "base64", 185 | ), 186 | }; 187 | } 188 | 189 | if (payChange) { 190 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 191 | payChange.satoshis = changeOutput.satoshis as number; 192 | payChange.txid = tx.id("hex") as string; 193 | } 194 | 195 | return { 196 | tx, 197 | spentOutpoints, 198 | payChange, 199 | }; 200 | }; 201 | -------------------------------------------------------------------------------- /src/sendUtxos.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { P2PKH, PrivateKey } from "@bsv/sdk"; 3 | import { sendUtxos } from "./sendUtxos"; 4 | import type { Utxo, Payment, SendUtxosConfig } from "./types"; 5 | 6 | describe("sendUtxos", () => { 7 | const paymentPk = PrivateKey.fromWif("KzwfqdfecMRtpg65j2BeRtixboNR37fSCDr8QbndV6ySEPT4xibW"); 8 | const address = paymentPk.toAddress().toString(); 9 | 10 | const insufficientUtxos: Utxo[] = [{ 11 | satoshis: 5, 12 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 13 | vout: 0, 14 | script: Buffer.from(new P2PKH().lock(address).toHex(), 'hex').toString('base64'), 15 | }]; 16 | 17 | const exactUtxos: Utxo[] = [{ 18 | satoshis: 12, 19 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 20 | vout: 0, 21 | script: Buffer.from(new P2PKH().lock(address).toHex(), 'hex').toString('base64'), 22 | }]; 23 | 24 | const sufficientUtxos: Utxo[] = [{ 25 | satoshis: 15, 26 | txid: "ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41", 27 | vout: 0, 28 | script: Buffer.from(new P2PKH().lock(address).toHex(), 'hex').toString('base64'), 29 | }]; 30 | 31 | const payments: Payment[] = [{ 32 | to: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", 33 | amount: 10 34 | }]; 35 | 36 | const baseConfig: SendUtxosConfig = { 37 | utxos: sufficientUtxos, 38 | paymentPk, 39 | payments, 40 | }; 41 | 42 | test("send utxos with a sufficient utxo", async () => { 43 | const { tx, spentOutpoints, payChange } = await sendUtxos(baseConfig); 44 | 45 | expect(tx).toBeDefined(); 46 | expect(tx.toHex()).toBeTruthy(); 47 | expect(spentOutpoints).toEqual(["ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41_0"]); 48 | expect(payChange?.vout).toBe(1); 49 | expect(tx.outputs.length).toBe(2); 50 | console.log({ txHex: tx.toHex() }); 51 | }); 52 | 53 | test("send utxos with an exact utxo", async () => { 54 | const config = { ...baseConfig, utxos: exactUtxos }; 55 | const { tx, spentOutpoints, payChange } = await sendUtxos(config); 56 | 57 | expect(tx).toBeDefined(); 58 | expect(tx.toHex()).toBeTruthy(); 59 | expect(spentOutpoints).toEqual(["ecb483eda58f26da1b1f8f15b782b1186abdf9c6399a1c3e63e0d429d5092a41_0"]); 60 | expect(payChange).toBeUndefined(); 61 | expect(tx.outputs.length).toBe(1); 62 | console.log({ txHex: tx.toHex() }); 63 | }); 64 | 65 | test("send utxos with insufficient utxo", async () => { 66 | const config = { ...baseConfig, utxos: insufficientUtxos }; 67 | await expect(sendUtxos(config)).rejects.toThrow("Not enough funds"); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/sendUtxos.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type PrivateKey, 3 | Transaction, 4 | SatoshisPerKilobyte, 5 | P2PKH, 6 | type TransactionOutput, 7 | Utils, 8 | Script, 9 | } from "@bsv/sdk"; 10 | import { DEFAULT_SAT_PER_KB } from "./constants"; 11 | import type { ChangeResult, SendUtxosConfig, Utxo } from "./types"; 12 | import { inputFromB64Utxo } from "./utils/utxo"; 13 | import OrdP2PKH from "./templates/ordP2pkh"; 14 | 15 | /** 16 | * Sends utxos to the given destination 17 | * @param {SendUtxosConfig} config - Configuration object for sending utxos 18 | * @param {Utxo[]} config.utxos - Utxos to spend (with base64 encoded scripts) 19 | * @param {PrivateKey} config.paymentPk - Private key to sign utxos 20 | * @param {Payment[]} config.payments - Array of payments with addresses and amounts 21 | * @param {number} [config.satsPerKb] - (Optional) Satoshis per kilobyte for fee calculation. Default is DEFAULT_SAT_PER_KB 22 | * @param {string} [config.changeAddress] - (Optional) Address to send change to. If not provided, defaults to paymentPk address 23 | * @param {string} [config.metaData] - (Optional) Metadata to include in OP_RETURN of the payment output 24 | * @returns {Promise} - Returns a ChangeResult: payChange, tx, and spentOutputs 25 | */ 26 | export const sendUtxos = async ( 27 | config: SendUtxosConfig, 28 | ): Promise => { 29 | const { 30 | utxos, 31 | paymentPk, 32 | payments, 33 | satsPerKb = DEFAULT_SAT_PER_KB, 34 | metaData, 35 | } = config; 36 | 37 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 38 | 39 | const tx = new Transaction(); 40 | 41 | // Outputs 42 | for (const payment of payments) { 43 | const sendTxOut: TransactionOutput = { 44 | satoshis: payment.amount, 45 | lockingScript: new OrdP2PKH().lock(payment.to, undefined, metaData), 46 | }; 47 | tx.addOutput(sendTxOut); 48 | } 49 | 50 | // Inputs 51 | let totalSatsIn = 0n; 52 | const totalSatsOut = tx.outputs.reduce( 53 | (total, out) => total + (out.satoshis || 0), 54 | 0, 55 | ); 56 | let fee = 0; 57 | for (const utxo of utxos) { 58 | const payKeyToUse = utxo.pk || paymentPk; 59 | if (!payKeyToUse) { 60 | throw new Error("Private key is required to sign the utxos"); 61 | } 62 | const input = inputFromB64Utxo(utxo, new P2PKH().unlock( 63 | payKeyToUse, 64 | "all", 65 | true, 66 | utxo.satoshis, 67 | Script.fromBinary(Utils.toArray(utxo.script, 'base64')) 68 | )); 69 | tx.addInput(input); 70 | 71 | // stop adding inputs if the total amount is enough 72 | totalSatsIn += BigInt(utxo.satoshis); 73 | fee = await modelOrFee.computeFee(tx); 74 | 75 | if (totalSatsIn >= totalSatsOut + fee) { 76 | break; 77 | } 78 | } 79 | 80 | // make sure we have enough 81 | if (totalSatsIn < totalSatsOut + fee) { 82 | throw new Error( 83 | `Not enough funds to send. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 84 | ); 85 | } 86 | 87 | // Change 88 | let payChange: Utxo | undefined; 89 | // if we need to send change, add it to the outputs 90 | if (totalSatsIn > totalSatsOut + fee) { 91 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 92 | if(!changeAddress) { 93 | throw new Error("Either changeAddress or paymentPk is required"); 94 | } 95 | const changeScript = new P2PKH().lock(changeAddress); 96 | const changeOut: TransactionOutput = { 97 | lockingScript: changeScript, 98 | change: true, 99 | }; 100 | payChange = { 101 | txid: "", // txid is not known yet 102 | vout: tx.outputs.length, 103 | satoshis: 0, // change output amount is not known yet 104 | script: Buffer.from(changeScript.toHex(), "hex").toString("base64"), 105 | }; 106 | tx.addOutput(changeOut); 107 | } else if (totalSatsIn < totalSatsOut + fee) { 108 | console.log("No change needed"); 109 | } 110 | 111 | // Calculate fee 112 | await tx.fee(modelOrFee); 113 | 114 | // Sign the transaction 115 | await tx.sign(); 116 | 117 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 118 | if (payChangeOutIdx !== -1) { 119 | const changeOutput = tx.outputs[payChangeOutIdx]; 120 | payChange = { 121 | satoshis: changeOutput.satoshis as number, 122 | txid: tx.id("hex") as string, 123 | vout: payChangeOutIdx, 124 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 125 | "base64", 126 | ), 127 | }; 128 | } 129 | 130 | if (payChange) { 131 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 132 | payChange.satoshis = changeOutput.satoshis as number; 133 | payChange.txid = tx.id("hex") as string; 134 | } 135 | 136 | return { 137 | tx, 138 | spentOutpoints: utxos.map((utxo) => `${utxo.txid}_${utxo.vout}`), 139 | payChange, 140 | }; 141 | }; 142 | -------------------------------------------------------------------------------- /src/signData.ts: -------------------------------------------------------------------------------- 1 | import type { Transaction } from "@bsv/sdk"; 2 | import { Sigma } from "sigma-protocol"; 3 | import type { LocalSigner, RemoteSigner } from "./types"; 4 | 5 | /** 6 | * Signs data in the transaction with Sigma protocol 7 | * @param {Transaction} tx - Transaction to sign 8 | * @param {LocalSigner | RemoteSigner} signer - Local or remote signer (used for data signature) 9 | * @returns {Transaction} Transaction with signed data 10 | */ 11 | export const signData = async ( 12 | tx: Transaction, 13 | signer: LocalSigner | RemoteSigner, 14 | ): Promise => { 15 | // Sign tx if idKey or remote signer like starfish/tokenpass 16 | const idKey = (signer as LocalSigner)?.idKey; 17 | const keyHost = (signer as RemoteSigner)?.keyHost; 18 | 19 | if (idKey) { 20 | const sigma = new Sigma(tx); 21 | const { signedTx } = sigma.sign(idKey); 22 | return signedTx; 23 | } 24 | if (keyHost) { 25 | const authToken = (signer as RemoteSigner)?.authToken; 26 | const sigma = new Sigma(tx); 27 | try { 28 | const { signedTx } = await sigma.remoteSign(keyHost, authToken); 29 | return signedTx; 30 | } catch (e) { 31 | console.log(e); 32 | throw new Error(`Remote signing to ${keyHost} failed`); 33 | } 34 | } 35 | throw new Error("Signer must be a LocalSigner or RemoteSigner"); 36 | }; 37 | -------------------------------------------------------------------------------- /src/templates/ordLock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BigNumber, 3 | type LockingScript, 4 | OP, 5 | P2PKH, 6 | type PrivateKey, 7 | Script, 8 | type Transaction, 9 | TransactionSignature, 10 | UnlockingScript, 11 | Utils, 12 | } from "@bsv/sdk"; 13 | import { toHex } from "../utils/strings"; 14 | import type { Inscription } from "../types"; 15 | 16 | export const oLockPrefix = 17 | "2097dfd76851bf465e8f715593b217714858bbe9570ff3bd5e33840a34e20ff0262102ba79df5f8ae7604a9830f03c7933028186aede0675a16f025dc4f8be8eec0382201008ce7480da41702918d1ec8e6849ba32b4d65b1e40dc669c31a1e6306b266c0000"; 18 | export const oLockSuffix = 19 | "615179547a75537a537a537a0079537a75527a527a7575615579008763567901c161517957795779210ac407f0e4bd44bfc207355a778b046225a7068fc59ee7eda43ad905aadbffc800206c266b30e6a1319c66dc401e5bd6b432ba49688eecd118297041da8074ce081059795679615679aa0079610079517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e01007e81517a75615779567956795679567961537956795479577995939521414136d08c5ed2bf3ba048afe6dcaebafeffffffffffffffffffffffffffffff00517951796151795179970079009f63007952799367007968517a75517a75517a7561527a75517a517951795296a0630079527994527a75517a6853798277527982775379012080517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f517f7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e7c7e01205279947f7754537993527993013051797e527e54797e58797e527e53797e52797e57797e0079517a75517a75517a75517a75517a75517a75517a75517a75517a75517a75517a75517a75517a756100795779ac517a75517a75517a75517a75517a75517a75517a75517a75517a7561517a75517a756169587951797e58797eaa577961007982775179517958947f7551790128947f77517a75517a75618777777777777777777767557951876351795779a9876957795779ac777777777777777767006868"; 20 | 21 | /** 22 | * OrdLock class implementing ScriptTemplate. 23 | * 24 | * This class provides methods for interacting with OrdinalLock contract 25 | */ 26 | export default class OrdLock { 27 | /** 28 | * Creates a 1Sat Ordinal Lock script 29 | * 30 | * @param {string} ordAddress - An address which can cancel listing. 31 | * @param {string} payAddress - Address which is paid on purchase 32 | * @param {number} price - Listing price in satoshis 33 | * @returns {LockingScript} - A P2PKH locking script. 34 | */ 35 | lock( 36 | ordAddress: string, 37 | payAddress: string, 38 | price: number, 39 | inscription?: Inscription, 40 | ): Script { 41 | const cancelPkh = Utils.fromBase58Check(ordAddress).data as number[]; 42 | const payPkh = Utils.fromBase58Check(payAddress).data as number[]; 43 | 44 | let script = new Script() 45 | if (inscription?.dataB64 !== undefined && inscription?.contentType !== undefined) { 46 | const ordHex = toHex("ord"); 47 | const fsBuffer = Buffer.from(inscription.dataB64, "base64"); 48 | const fileHex = fsBuffer.toString("hex").trim(); 49 | if (!fileHex) { 50 | throw new Error("Invalid file data"); 51 | } 52 | const fileMediaType = toHex(inscription.contentType); 53 | if (!fileMediaType) { 54 | throw new Error("Invalid media type"); 55 | } 56 | script = Script.fromASM(`OP_0 OP_IF ${ordHex} OP_1 ${fileMediaType} OP_0 ${fileHex} OP_ENDIF`); 57 | } 58 | 59 | return script.writeScript(Script.fromHex(oLockPrefix)) 60 | .writeBin(cancelPkh) 61 | .writeBin(OrdLock.buildOutput(price, new P2PKH().lock(payPkh).toBinary())) 62 | .writeScript(Script.fromHex(oLockSuffix)) 63 | } 64 | 65 | cancelListing( 66 | privateKey: PrivateKey, 67 | signOutputs: 'all' | 'none' | 'single' = 'all', 68 | anyoneCanPay = false, 69 | sourceSatoshis?: number, 70 | lockingScript?: Script 71 | ): { 72 | sign: (tx: Transaction, inputIndex: number) => Promise 73 | estimateLength: () => Promise 74 | } { 75 | const p2pkh = new P2PKH().unlock(privateKey, signOutputs, anyoneCanPay, sourceSatoshis, lockingScript) 76 | return { 77 | sign: async (tx: Transaction, inputIndex: number) => { 78 | return (await p2pkh.sign(tx, inputIndex)).writeOpCode(OP.OP_1) 79 | }, 80 | estimateLength: async () => { 81 | return 107 82 | } 83 | } 84 | } 85 | 86 | purchaseListing( 87 | sourceSatoshis?: number, 88 | lockingScript?: Script 89 | ): { 90 | sign: (tx: Transaction, inputIndex: number) => Promise 91 | estimateLength: (tx: Transaction, inputIndex: number) => Promise 92 | } { 93 | const purchase = { 94 | sign: async (tx: Transaction, inputIndex: number) => { 95 | if (tx.outputs.length < 2) { 96 | throw new Error("Malformed transaction") 97 | } 98 | const script = new UnlockingScript() 99 | .writeBin(OrdLock.buildOutput( 100 | tx.outputs[0].satoshis || 0, 101 | tx.outputs[0].lockingScript.toBinary() 102 | )) 103 | if (tx.outputs.length > 2) { 104 | const writer = new Utils.Writer() 105 | for (const output of tx.outputs.slice(2)) { 106 | writer.write(OrdLock.buildOutput(output.satoshis || 0, output.lockingScript.toBinary())) 107 | } 108 | script.writeBin(writer.toArray()) 109 | } else { 110 | script.writeOpCode(OP.OP_0) 111 | } 112 | 113 | const input = tx.inputs[inputIndex] 114 | let sourceSats = sourceSatoshis as number 115 | if (!sourceSats && input.sourceTransaction) { 116 | sourceSats = input.sourceTransaction.outputs[input.sourceOutputIndex].satoshis as number 117 | } else if (!sourceSatoshis) { 118 | throw new Error("sourceTransaction or sourceSatoshis is required") 119 | } 120 | 121 | const sourceTXID = (input.sourceTXID || input.sourceTransaction?.id('hex')) as string 122 | let subscript = lockingScript as LockingScript 123 | if (!subscript) { 124 | subscript = input.sourceTransaction?.outputs[input.sourceOutputIndex].lockingScript as LockingScript 125 | } 126 | const preimage = TransactionSignature.format({ 127 | sourceTXID, 128 | sourceOutputIndex: input.sourceOutputIndex, 129 | sourceSatoshis: sourceSats, 130 | transactionVersion: tx.version, 131 | otherInputs: [], 132 | inputIndex, 133 | outputs: tx.outputs, 134 | inputSequence: input.sequence, 135 | subscript, 136 | lockTime: tx.lockTime, 137 | scope: TransactionSignature.SIGHASH_ALL | 138 | TransactionSignature.SIGHASH_ANYONECANPAY | 139 | TransactionSignature.SIGHASH_FORKID 140 | }); 141 | 142 | return script.writeBin(preimage).writeOpCode(OP.OP_0) 143 | }, 144 | estimateLength: async (tx: Transaction, inputIndex: number) => { 145 | return (await purchase.sign(tx, inputIndex)).toBinary().length 146 | } 147 | } 148 | return purchase 149 | } 150 | 151 | static buildOutput(satoshis: number, script: number[]): number[] { 152 | const writer = new Utils.Writer() 153 | writer.writeUInt64LEBn(new BigNumber(satoshis)) 154 | writer.writeVarIntNum(script.length) 155 | writer.write(script) 156 | return writer.toArray() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/templates/ordP2pkh.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LockingScript, 3 | P2PKH, 4 | type Script, 5 | } from "@bsv/sdk"; 6 | import type { Inscription, MAP } from "../types"; 7 | import { toHex } from "../utils/strings"; 8 | import { MAP_PREFIX } from "../constants"; 9 | 10 | /** 11 | * OrdP2PKH (1Sat Ordinal + Pay To Public Key Hash) class implementing ScriptTemplate. 12 | * 13 | * This class provides methods to create an Ordinal with Pay To Public Key Hash locking and unlocking scripts. 14 | * It extends the standard P2PKH script template and provides a custom lock method. 15 | */ 16 | export default class OrdP2PKH extends P2PKH { 17 | /** 18 | * Creates a 1Sat Ordinal + P2PKH locking script for a given address string 19 | * 20 | * @param {string} address - An destination address for the Ordinal. 21 | * @param {Object} [inscription] - Base64 encoded file data and Content type of the file. 22 | * @param {MAP} [metaData] - (optional) MAP Metadata to be included in OP_RETURN. 23 | * @returns {LockingScript} - A P2PKH locking script. 24 | */ 25 | // unlock method inherits from p2pkh 26 | lock( 27 | address: string, 28 | inscription?: Inscription, 29 | metaData?: MAP | undefined, 30 | ): Script { 31 | // Create ordinal output and inscription in a single output 32 | const lockingScript = new P2PKH().lock(address); 33 | return applyInscription(lockingScript, inscription, metaData); 34 | } 35 | } 36 | 37 | export const applyInscription = (lockingScript: LockingScript, inscription?: Inscription, metaData?: MAP, withSeparator=false) => { 38 | let ordAsm = ""; 39 | // This can be omitted for reinscriptions that just update metadata 40 | if (inscription?.dataB64 !== undefined && inscription?.contentType !== undefined) { 41 | const ordHex = toHex("ord"); 42 | const fsBuffer = Buffer.from(inscription.dataB64, "base64"); 43 | const fileHex = fsBuffer.toString("hex").trim(); 44 | if (!fileHex) { 45 | throw new Error("Invalid file data"); 46 | } 47 | const fileMediaType = toHex(inscription.contentType); 48 | if (!fileMediaType) { 49 | throw new Error("Invalid media type"); 50 | } 51 | ordAsm = `OP_0 OP_IF ${ordHex} OP_1 ${fileMediaType} OP_0 ${fileHex} OP_ENDIF`; 52 | } 53 | 54 | let inscriptionAsm = `${ordAsm ? `${ordAsm} ${withSeparator ? 'OP_CODESEPARATOR ' : ''}` : ""}${lockingScript.toASM()}`; 55 | 56 | // MAP.app and MAP.type keys are required 57 | if (metaData && (!metaData.app || !metaData.type)) { 58 | throw new Error("MAP.app and MAP.type are required fields"); 59 | } 60 | 61 | if (metaData?.app && metaData?.type) { 62 | const mapPrefixHex = toHex(MAP_PREFIX); 63 | const mapCmdValue = toHex("SET"); 64 | inscriptionAsm = `${inscriptionAsm ? `${inscriptionAsm} ` : ""}OP_RETURN ${mapPrefixHex} ${mapCmdValue}`; 65 | 66 | for (const [key, value] of Object.entries(metaData)) { 67 | if (key !== "cmd") { 68 | inscriptionAsm = `${inscriptionAsm} ${toHex(key)} ${toHex( 69 | value as string, 70 | )}`; 71 | } 72 | } 73 | } 74 | 75 | return LockingScript.fromASM(inscriptionAsm); 76 | } 77 | -------------------------------------------------------------------------------- /src/testdata/invalid_400x300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BitcoinSchema/js-1sat-ord/04f48a39ae2e22767568c45e8e964da925bbc1c2/src/testdata/invalid_400x300.png -------------------------------------------------------------------------------- /src/testdata/valid_300x300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BitcoinSchema/js-1sat-ord/04f48a39ae2e22767568c45e8e964da925bbc1c2/src/testdata/valid_300x300.png -------------------------------------------------------------------------------- /src/transferOrdinals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | P2PKH, 3 | SatoshisPerKilobyte, 4 | Script, 5 | Transaction, 6 | Utils, 7 | } from "@bsv/sdk"; 8 | import { DEFAULT_SAT_PER_KB } from "./constants"; 9 | import OrdP2PKH, { applyInscription } from "./templates/ordP2pkh"; 10 | import { 11 | TokenType, 12 | type TokenUtxo, 13 | type TransferBSV20Inscription, 14 | type TransferBSV21Inscription, 15 | type TransferOrdTokensConfig, 16 | type TokenChangeResult, 17 | type TransferTokenInscription, 18 | type Utxo, 19 | TokenInputMode, 20 | type TokenSplitConfig, 21 | type PreMAP, 22 | } from "./types"; 23 | import { inputFromB64Utxo } from "./utils/utxo"; 24 | import { signData } from "./signData"; 25 | import stringifyMetaData from "./utils/subtypeData"; 26 | import { ReturnTypes, toToken, toTokenSat } from "satoshi-token"; 27 | 28 | /** 29 | * Transfer tokens to a destination 30 | * @param {TransferOrdTokensConfig} config - Configuration object for transferring tokens 31 | * @param {TokenType} config.protocol - Token protocol. Must be TokenType.BSV20 or TokenType.BSV21 32 | * @param {string} config.tokenID - Token ID. Either the tick or id value depending on the protocol 33 | * @param {Utxo[]} config.utxos - Payment Utxos available to spend. Will only consume what is needed. 34 | * @param {TokenUtxo[]} config.inputTokens - Token utxos to spend 35 | * @param {Distribution[]} config.distributions - Array of destinations with addresses and amounts 36 | * @param {PrivateKey} config.paymentPk - Private key to sign paymentUtxos 37 | * @param {PrivateKey} config.ordPk - Private key to sign ordinals 38 | * @param {decimals} config.decimals - Number of decimal places for the token 39 | * @param {string} [config.changeAddress] - Optional. Address to send payment change to, if any. If not provided, defaults to paymentPk address 40 | * @param {string} [config.tokenChangeAddress] - Optional. Address to send token change to, if any. If not provided, defaults to ordPk address 41 | * @param {number} [config.satsPerKb] - Optional. Satoshis per kilobyte for fee calculation. Default is DEFAULT_SAT_PER_KB 42 | * @param {PreMAP} [config.metaData] - Optional. MAP (Magic Attribute Protocol) metadata to include in inscriptions 43 | * @param {LocalSigner | RemoteSigner} [config.signer] - Optional. Signer object to sign the transaction 44 | * @param {Payment[]} [config.additionalPayments] - Optional. Additional payments to include in the transaction 45 | * @param {TokenInputMode} [config.tokenInputMode] - Optional. "all" or "needed". Default is "needed" 46 | * @param {TokenSplitConfig} [config.tokenSplitConfig] - Optional. Configuration object for splitting token change 47 | * @param {burn} [config.burn] - Optional. Set to true to burn the tokens. 48 | * @returns {Promise} Transaction with token transfer outputs 49 | */ 50 | export const transferOrdTokens = async ( 51 | config: TransferOrdTokensConfig, 52 | ): Promise => { 53 | const { 54 | protocol, 55 | tokenID, 56 | utxos, 57 | inputTokens, 58 | distributions, 59 | paymentPk, 60 | ordPk, 61 | satsPerKb = DEFAULT_SAT_PER_KB, 62 | metaData, 63 | signer, 64 | decimals, 65 | additionalPayments = [], 66 | burn = false, 67 | tokenInputMode = TokenInputMode.Needed, 68 | splitConfig = { 69 | outputs: 1, 70 | omitMetaData: false, 71 | }, 72 | } = config; 73 | 74 | // Ensure these inputs are for the expected token 75 | if (!inputTokens.every((token) => token.id === tokenID)) { 76 | throw new Error("Input tokens do not match the provided tokenID"); 77 | } 78 | 79 | // calculate change amount 80 | let changeTsats = 0n; 81 | let totalTsatIn = 0n; 82 | let totalTsatOut = 0n; 83 | const totalAmtNeeded = distributions.reduce( 84 | (acc, dist) => acc + toTokenSat(dist.tokens, decimals, ReturnTypes.BigInt), 85 | 0n, 86 | ); 87 | 88 | const modelOrFee = new SatoshisPerKilobyte(satsPerKb); 89 | let tx = new Transaction(); 90 | 91 | // Handle token inputs based on tokenInputMode 92 | let tokensToUse: TokenUtxo[]; 93 | if (tokenInputMode === TokenInputMode.All) { 94 | tokensToUse = inputTokens; 95 | totalTsatIn = inputTokens.reduce( 96 | (acc, token) => acc + BigInt(token.amt), 97 | 0n, 98 | ); 99 | } else { 100 | tokensToUse = []; 101 | for (const token of inputTokens) { 102 | tokensToUse.push(token); 103 | totalTsatIn += BigInt(token.amt); 104 | if (totalTsatIn >= totalAmtNeeded) { 105 | break; 106 | } 107 | } 108 | if (totalTsatIn < totalAmtNeeded) { 109 | throw new Error("Not enough tokens to satisfy the transfer amount"); 110 | } 111 | } 112 | 113 | for (const token of tokensToUse) { 114 | const ordKeyToUse = token.pk || ordPk; 115 | if(!ordKeyToUse) { 116 | throw new Error("Private key required for token input"); 117 | } 118 | const inputScriptBinary = Utils.toArray(token.script, "base64"); 119 | const inputScript = Script.fromBinary(inputScriptBinary); 120 | tx.addInput( 121 | inputFromB64Utxo( 122 | token, 123 | new OrdP2PKH().unlock(ordKeyToUse, "all", true, token.satoshis, inputScript), 124 | ), 125 | ); 126 | } 127 | 128 | // remove any undefined fields from metadata 129 | if (metaData) { 130 | for (const key of Object.keys(metaData)) { 131 | if (metaData[key] === undefined) { 132 | delete metaData[key]; 133 | } 134 | } 135 | } 136 | 137 | // build destination inscriptions 138 | for (const dest of distributions) { 139 | const bigAmt = toTokenSat(dest.tokens, decimals, ReturnTypes.BigInt); 140 | 141 | const transferInscription: TransferTokenInscription = { 142 | p: "bsv-20", 143 | op: burn ? "burn" : "transfer", 144 | amt: bigAmt.toString(), 145 | }; 146 | let inscriptionObj: TransferBSV20Inscription | TransferBSV21Inscription; 147 | if (protocol === TokenType.BSV20) { 148 | inscriptionObj = { 149 | ...transferInscription, 150 | tick: tokenID, 151 | } as TransferBSV20Inscription; 152 | } else if (protocol === TokenType.BSV21) { 153 | inscriptionObj = { 154 | ...transferInscription, 155 | id: tokenID, 156 | } as TransferBSV21Inscription; 157 | } else { 158 | throw new Error("Invalid protocol"); 159 | } 160 | 161 | const inscription = { 162 | dataB64: Buffer.from(JSON.stringify(inscriptionObj)).toString("base64"), 163 | contentType: "application/bsv-20", 164 | } 165 | const lockingScript = typeof dest.address === 'string' ? 166 | new OrdP2PKH().lock( 167 | dest.address, 168 | inscription, 169 | // when present, include metadata on each distribution if omit is not specified 170 | dest.omitMetaData ? undefined : stringifyMetaData(metaData), 171 | ) : 172 | applyInscription(dest.address, inscription); 173 | 174 | tx.addOutput({ 175 | satoshis: 1, 176 | lockingScript, 177 | }); 178 | totalTsatOut += bigAmt; 179 | } 180 | 181 | changeTsats = totalTsatIn - totalTsatOut; 182 | 183 | // check that you have enough tokens to send and return change 184 | if (changeTsats < 0n) { 185 | throw new Error("Not enough tokens to send"); 186 | } 187 | 188 | let tokenChange: TokenUtxo[] = []; 189 | if (changeTsats > 0n) { 190 | const tokenChangeAddress = config.tokenChangeAddress || ordPk?.toAddress(); 191 | if(!tokenChangeAddress) { 192 | throw new Error("ordPk or changeAddress required for token change"); 193 | } 194 | tokenChange = splitChangeOutputs( 195 | tx, 196 | changeTsats, 197 | protocol, 198 | tokenID, 199 | tokenChangeAddress, 200 | metaData, 201 | splitConfig, 202 | decimals, 203 | ); 204 | } 205 | 206 | // Add additional payments if any 207 | for (const p of additionalPayments) { 208 | tx.addOutput({ 209 | satoshis: p.amount, 210 | lockingScript: new P2PKH().lock(p.to), 211 | }); 212 | } 213 | 214 | // add change to the outputs 215 | let payChange: Utxo | undefined; 216 | const changeAddress = config.changeAddress || paymentPk?.toAddress(); 217 | if (!changeAddress) { 218 | throw new Error("paymentPk or changeAddress required for payment change"); 219 | } 220 | const changeScript = new P2PKH().lock(changeAddress); 221 | const changeOut = { 222 | lockingScript: changeScript, 223 | change: true, 224 | }; 225 | tx.addOutput(changeOut); 226 | 227 | let totalSatsIn = 0n; 228 | const totalSatsOut = tx.outputs.reduce( 229 | (total, out) => total + BigInt(out.satoshis || 0), 230 | 0n, 231 | ); 232 | let fee = 0; 233 | for (const utxo of utxos) { 234 | const payKeyToUse = utxo.pk || paymentPk; 235 | if(!payKeyToUse) { 236 | throw new Error("paymentPk required for payment utxo"); 237 | } 238 | const input = inputFromB64Utxo( 239 | utxo, 240 | new P2PKH().unlock( 241 | payKeyToUse, 242 | "all", 243 | true, 244 | utxo.satoshis, 245 | Script.fromBinary(Utils.toArray(utxo.script, "base64")), 246 | ), 247 | ); 248 | 249 | tx.addInput(input); 250 | // stop adding inputs if the total amount is enough 251 | totalSatsIn += BigInt(utxo.satoshis); 252 | fee = await modelOrFee.computeFee(tx); 253 | 254 | if (totalSatsIn >= totalSatsOut + BigInt(fee)) { 255 | break; 256 | } 257 | } 258 | 259 | // make sure we have enough 260 | if (totalSatsIn < totalSatsOut + BigInt(fee)) { 261 | throw new Error( 262 | `Not enough funds to transfer tokens. Total sats in: ${totalSatsIn}, Total sats out: ${totalSatsOut}, Fee: ${fee}`, 263 | ); 264 | } 265 | 266 | if (signer) { 267 | tx = await signData(tx, signer); 268 | } 269 | 270 | // estimate the cost of the transaction and assign change value 271 | await tx.fee(modelOrFee); 272 | 273 | // Sign the transaction 274 | await tx.sign(); 275 | 276 | // assign txid to tokenChange outputs 277 | const txid = tx.id("hex") as string; 278 | for (const change of tokenChange) { 279 | change.txid = txid; 280 | } 281 | 282 | // check for change 283 | const payChangeOutIdx = tx.outputs.findIndex((o) => o.change); 284 | if (payChangeOutIdx !== -1) { 285 | const changeOutput = tx.outputs[payChangeOutIdx]; 286 | payChange = { 287 | satoshis: changeOutput.satoshis as number, 288 | txid, 289 | vout: payChangeOutIdx, 290 | script: Buffer.from(changeOutput.lockingScript.toBinary()).toString( 291 | "base64", 292 | ), 293 | }; 294 | } 295 | 296 | if (payChange) { 297 | const changeOutput = tx.outputs[tx.outputs.length - 1]; 298 | payChange.satoshis = changeOutput.satoshis as number; 299 | payChange.txid = tx.id("hex") as string; 300 | } 301 | 302 | return { 303 | tx, 304 | spentOutpoints: tx.inputs.map( 305 | (i) => `${i.sourceTXID}_${i.sourceOutputIndex}`, 306 | ), 307 | payChange, 308 | tokenChange, 309 | }; 310 | }; 311 | 312 | const splitChangeOutputs = ( 313 | tx: Transaction, 314 | changeTsats: bigint, 315 | protocol: TokenType, 316 | tokenID: string, 317 | tokenChangeAddress: string, 318 | metaData: PreMAP | undefined, 319 | splitConfig: TokenSplitConfig, 320 | decimals: number, 321 | ): TokenUtxo[] => { 322 | const tokenChanges: TokenUtxo[] = []; 323 | 324 | const threshold = splitConfig.threshold !== undefined ? toTokenSat(splitConfig.threshold, decimals, ReturnTypes.BigInt) : undefined; 325 | const maxOutputs = splitConfig.outputs; 326 | const changeAmt = changeTsats; 327 | let splitOutputs: bigint; 328 | if (threshold !== undefined && threshold > 0n) { 329 | splitOutputs = changeAmt / threshold; 330 | splitOutputs = BigInt(Math.min(Number(splitOutputs), maxOutputs)); 331 | } else { 332 | // If no threshold is specified, use maxOutputs directly 333 | splitOutputs = BigInt(maxOutputs); 334 | } 335 | splitOutputs = BigInt(Math.max(Number(splitOutputs), 1)); 336 | 337 | const baseChangeAmount = changeAmt / splitOutputs; 338 | let remainder = changeAmt % splitOutputs; 339 | 340 | for (let i = 0n; i < splitOutputs; i++) { 341 | let splitAmount = baseChangeAmount; 342 | if (remainder > 0n) { 343 | splitAmount += 1n; 344 | remainder -= 1n; 345 | } 346 | 347 | const transferInscription: TransferTokenInscription = { 348 | p: "bsv-20", 349 | op: "transfer", 350 | amt: splitAmount.toString(), 351 | }; 352 | let inscription: TransferBSV20Inscription | TransferBSV21Inscription; 353 | if (protocol === TokenType.BSV20) { 354 | inscription = { 355 | ...transferInscription, 356 | tick: tokenID, 357 | } as TransferBSV20Inscription; 358 | } else if (protocol === TokenType.BSV21) { 359 | inscription = { 360 | ...transferInscription, 361 | id: tokenID, 362 | } as TransferBSV21Inscription; 363 | } else { 364 | throw new Error("Invalid protocol"); 365 | } 366 | 367 | const lockingScript = new OrdP2PKH().lock( 368 | tokenChangeAddress, 369 | { 370 | dataB64: Buffer.from(JSON.stringify(inscription)).toString("base64"), 371 | contentType: "application/bsv-20", 372 | }, 373 | splitConfig.omitMetaData ? undefined : stringifyMetaData(metaData), 374 | ); 375 | 376 | const vout = tx.outputs.length; 377 | tx.addOutput({ lockingScript, satoshis: 1 }); 378 | tokenChanges.push({ 379 | id: tokenID, 380 | satoshis: 1, 381 | script: Buffer.from(lockingScript.toBinary()).toString("base64"), 382 | txid: "", 383 | vout, 384 | amt: splitAmount.toString(), 385 | }); 386 | } 387 | 388 | return tokenChanges; 389 | }; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { PrivateKey, Script, Transaction } from "@bsv/sdk"; 2 | import type { AuthToken } from "sigma-protocol"; 3 | 4 | // biome-ignore lint/complexity/noBannedTypes: Reserved for future use 5 | type Signer = {}; 6 | 7 | export interface LocalSigner extends Signer { 8 | idKey: PrivateKey; 9 | } 10 | 11 | export interface RemoteSigner extends Signer { 12 | keyHost: string; 13 | authToken?: AuthToken; 14 | } 15 | 16 | export type Destination = { 17 | address: string; 18 | inscription?: Inscription; 19 | }; 20 | 21 | /** 22 | * @typedef {Object} Listing 23 | * @property {string} payAddress - Address to send the payment upon purchase 24 | * @property {string} price - Listing price in satoshis 25 | * @property {String} ordAddress - Where to return a listed ordinal upon cancel. 26 | * @property {Utxo} listingUtxo - Utxo of the listing 27 | */ 28 | export type NewListing = { 29 | payAddress: string; 30 | price: number; 31 | ordAddress: string; 32 | listingUtxo: Utxo; 33 | } 34 | 35 | /** 36 | * @typedef {Object} ExistingListing 37 | * @property {string} payout - Payment output script base64 encoded 38 | * @property {Utxo} listingUtxo - Utxo of the listing 39 | */ 40 | export type ExistingListing = { 41 | payout: string; 42 | listingUtxo: Utxo; 43 | } 44 | 45 | /** 46 | * @typedef {Object} NewTokenListing 47 | * @property {string} payAddress - Address to send the payment upon purchase 48 | * @property {string} price - Listing price in satoshis 49 | * @property {String} ordAddress - Where to return a listed ordinal upon cancel. 50 | * @property {number} tokens - Number of tokens in whole token display format. Ex. 0.5 for 0.5 tokens. Library handles conversion to 'tsat' format. 51 | */ 52 | export type NewTokenListing = { 53 | payAddress: string; 54 | price: number; 55 | tokens: number; 56 | ordAddress: string; 57 | } 58 | 59 | /** 60 | * @typedef {Object} Distribution 61 | * @property {string} address - Destination address. Must be a Ordinals address (BSV address for recieving 1Sat ordinals tokens). 62 | * @property {number} tokens - Number of tokens in whole token display format. Ex. 0.5 for 0.5 tokens. Library handles conversion to 'tsat' format. 63 | * @property {boolean} [omitMetaData] - Optional. Set to true to omit metadata from this distribution's output. 64 | */ 65 | export type Distribution = { 66 | address: string | Script; 67 | tokens: number; 68 | omitMetaData?: boolean; 69 | }; 70 | 71 | /** 72 | * @typedef {Object} Utxo 73 | * @property {number} satoshis - Amount in satoshis 74 | * @property {string} txid - Transaction id 75 | * @property {number} vout - Output index 76 | * @property {string} script - Base64 encoded locking script 77 | * @property {PrivateKey} [pk] - Optional. Private key for unlocking this utxo 78 | */ 79 | export type Utxo = { 80 | satoshis: number; 81 | txid: string; 82 | vout: number; 83 | script: string; 84 | pk?: PrivateKey; 85 | }; 86 | 87 | /** 88 | * @typedef {Object} NftUtxo 89 | * @property {string} collectionId - Optional. Collection id of the NFT 90 | * @property {string} contentType - Media type of the NFT 91 | * @property {string} creatorBapId - Optional. Creator BAP id of the NFT 92 | * @property {string} origin - Origin address of the NFT 93 | * @property {number} satoshis - Always 1 94 | * @property {PrivateKey} [pk] - Optional. Private key for unlocking this utxo 95 | */ 96 | export interface NftUtxo extends Utxo { 97 | collectionId?: string; 98 | contentType: string; 99 | creatorBapId?: string; 100 | origin: string; 101 | satoshis: 1; 102 | pk?: PrivateKey; 103 | } 104 | 105 | /** 106 | * @typedef {Object} TokenUtxo 107 | * @property {string} amt - Number of tokens as a string in 'tsat' format. Ex. 100000000 for 1 token with 8 decimal places. 108 | * @property {string} id - Token id - either tick or id depending on protocol 109 | * @property {string} satoshis - Always 1 110 | * @property {string} [payout] - Optional. Payment output script base64 encoded 111 | * @property {number} [price] - Optional. Listing price in satoshis 112 | * @property {boolean} [isListing] - Optional. True if the token is a listing 113 | * @property {PrivateKey} [pk] - Optional. Private key for unlocking this utxo 114 | */ 115 | export interface TokenUtxo extends Utxo { 116 | amt: string; 117 | id: string; 118 | satoshis: 1; 119 | payout?: string; 120 | price?: number; 121 | isListing?: boolean; 122 | pk?: PrivateKey; 123 | } 124 | 125 | export enum TokenSelectionStrategy { 126 | SmallestFirst = "smallest", 127 | LargestFirst = "largest", 128 | RetainOrder = "retain", 129 | Random = "random", 130 | } 131 | 132 | export interface TokenSelectionOptions { 133 | inputStrategy?: TokenSelectionStrategy; 134 | outputStrategy?: TokenSelectionStrategy; 135 | } 136 | 137 | export interface TokenSelectionResult { 138 | selectedUtxos: TokenUtxo[]; 139 | totalSelected: number; 140 | isEnough: boolean; 141 | } 142 | 143 | export type Inscription = { 144 | dataB64: string; 145 | contentType: string; 146 | }; 147 | 148 | export type ImageContentType = 149 | | "image/png" 150 | | "image/jpeg" 151 | | "image/gif" 152 | | "image/svg+xml" 153 | | "image/webp"; 154 | 155 | /** 156 | * @typedef {Object} IconInscription 157 | * @property {string} dataB64 - Base64 encoded image data. Must be a square image. 158 | * @property {ImageContentType} contentType - Media type of the image 159 | */ 160 | export type IconInscription = { 161 | dataB64: string; 162 | contentType: ImageContentType; 163 | }; 164 | 165 | export type Payment = { 166 | to: string; 167 | amount: number; 168 | }; 169 | 170 | export type TokenInscription = { 171 | p: "bsv-20"; 172 | amt: string; 173 | op: "transfer" | "mint" | "deploy+mint" | "burn"; 174 | dec?: string; 175 | }; 176 | 177 | export interface MintTokenInscription extends TokenInscription { 178 | op: "mint"; 179 | } 180 | 181 | export interface DeployMintTokenInscription extends TokenInscription { 182 | op: "deploy+mint"; 183 | sym: string; 184 | icon: string; 185 | } 186 | 187 | export interface TransferTokenInscription extends TokenInscription { 188 | p: "bsv-20"; 189 | amt: string; 190 | op: "transfer" | "burn"; 191 | } 192 | 193 | export interface TransferBSV20Inscription extends TransferTokenInscription { 194 | tick: string; 195 | } 196 | 197 | export interface TransferBSV21Inscription extends TransferTokenInscription { 198 | id: string; 199 | } 200 | 201 | export enum TokenType { 202 | BSV20 = "bsv20", 203 | BSV21 = "bsv21", 204 | } 205 | 206 | export type BaseResult = { 207 | tx: Transaction; 208 | spentOutpoints: string[]; 209 | }; 210 | 211 | export interface ChangeResult extends BaseResult { 212 | payChange?: Utxo; 213 | }; 214 | 215 | /** 216 | * MAP (Magic Attribute Protocol) metadata object with stringified values for writing to the blockchain 217 | * @typedef {Object} MAP 218 | * @property {string} app - Application identifier 219 | * @property {string} type - Metadata type 220 | * @property {string} [prop] - Optional. Additional metadata properties 221 | */ 222 | export type MAP = { 223 | app: string; 224 | type: string; 225 | [prop: string]: string; 226 | }; 227 | 228 | export type PreMAP = { 229 | app: string; 230 | type: string; 231 | [prop: string]: unknown; 232 | royalties?: Royalty[]; 233 | subTypeData?: CollectionSubTypeData | CollectionItemSubTypeData; 234 | }; 235 | 236 | export type CreateOrdinalsConfig = { 237 | utxos: Utxo[]; 238 | destinations: Destination[]; 239 | paymentPk?: PrivateKey; 240 | changeAddress?: string; 241 | satsPerKb?: number; 242 | metaData?: PreMAP; 243 | signer?: LocalSigner | RemoteSigner; 244 | additionalPayments?: Payment[]; 245 | }; 246 | 247 | export enum RoytaltyType { 248 | Paymail = "paymail", 249 | Address = "address", 250 | Script = "script", 251 | } 252 | 253 | /** 254 | * Royalty object 255 | * @typedef {Object} Royalty 256 | * @property {RoytaltyType} type - Royalty type, string, one of "paymail", "address", "script" 257 | * @property {string} destination - Royalty destination 258 | * @property {string} percentage - Royalty percentage as a string float 0-1 (0.01 = 1%) 259 | */ 260 | export type Royalty = { 261 | type: RoytaltyType; 262 | destination: string; 263 | percentage: string; // string float 0-1 264 | }; 265 | 266 | export interface CreateOrdinalsMetadata extends PreMAP { 267 | type: "ord", 268 | name: string, 269 | previewUrl?: string, 270 | } 271 | 272 | export interface CreateOrdinalsCollectionMetadata extends CreateOrdinalsMetadata { 273 | subType: "collection", 274 | subTypeData: CollectionSubTypeData, // JSON stringified CollectionSubTypeData 275 | royalties?: Royalty[], 276 | }; 277 | 278 | export interface CreateOrdinalsCollectionItemMetadata extends CreateOrdinalsMetadata { 279 | subType: "collectionItem", 280 | subTypeData: CollectionItemSubTypeData, // JSON stringified CollectionItemSubTypeData 281 | }; 282 | 283 | /** 284 | * Configuration object for creating an ordinals collection 285 | * @typedef {Object} CreateOrdinalsCollectionConfig 286 | * @property metaData - MAP (Magic Attribute Protocol) metadata for the collection 287 | * @property metaData.type - "ord" 288 | * @property metaData.subType - "collection" 289 | * @property metaData.name - Collection name 290 | * @property metaData.subTypeData - JSON stringified CollectionSubTypeData 291 | * @property [metaData.royalties] - Optional. Royalties address 292 | * @property [metaData.previewUrl] - Optional. Preview URL 293 | */ 294 | export interface CreateOrdinalsCollectionConfig extends CreateOrdinalsConfig { 295 | metaData: CreateOrdinalsCollectionMetadata 296 | } 297 | 298 | export type CollectionTraits = { 299 | [trait: string]: CollectionTrait; 300 | }; 301 | 302 | export type CollectionTrait = { 303 | values: string[]; 304 | occurancePercentages: string[]; 305 | }; 306 | 307 | export type Rarity = { 308 | [key: string]: string; 309 | } 310 | 311 | export type RarityLabels = Rarity[] 312 | export interface CollectionSubTypeData { 313 | description: string; 314 | quantity: number; 315 | rarityLabels: RarityLabels; 316 | traits: CollectionTraits; 317 | } 318 | 319 | export interface CreateOrdinalsCollectionItemMetadata extends PreMAP { 320 | type: "ord", 321 | name: string, 322 | subType: "collectionItem", 323 | subTypeData: CollectionItemSubTypeData, // JSON stringified CollectionItemSubTypeData 324 | previewUrl?: string, 325 | } 326 | 327 | /** 328 | * Configuration object for creating an ordinals collection item 329 | * @typedef {Object} CreateOrdinalsCollectionItemConfig 330 | * @property metaData - MAP (Magic Attribute Protocol) metadata for the collection item 331 | * @property metaData.type - "ord" 332 | * @property metaData.subType - "collectionItem" 333 | * @property metaData.name - Collection item name 334 | * @property metaData.subTypeData - JSON stringified CollectionItemSubTypeData 335 | * @property [metaData.royalties] - Optional. Royalties address 336 | * @property [metaData.previewUrl] - Optional. Preview URL 337 | */ 338 | export interface CreateOrdinalsCollectionItemConfig extends CreateOrdinalsConfig { 339 | metaData: CreateOrdinalsCollectionItemMetadata 340 | } 341 | 342 | /** 343 | * Subtype data for an ordinals collection item 344 | * @typedef {Object} CollectionItemSubTypeData 345 | * @property {string} collectionId - Collection id 346 | * @property {number} mintNumner - Mint number 347 | * @property {number} rank - Rank 348 | * @property {string} rarityLabel - Rarity label 349 | * @property {string} traits - traits object 350 | * @property {string} attachments - array of attachment objects 351 | */ 352 | export interface CollectionItemSubTypeData { 353 | collectionId: string; 354 | mintNumber?: number; 355 | rank?: number; 356 | rarityLabel?: RarityLabels; 357 | traits?: CollectionItemTrait[]; 358 | attachments?: CollectionItemAttachment[]; 359 | } 360 | 361 | export type CollectionItemTrait = { 362 | name: string; 363 | value: string; 364 | rarityLabel?: string; 365 | occurancePercentrage?: string; 366 | }; 367 | 368 | export type CollectionItemAttachment = { 369 | name: string; 370 | description?: string; 371 | "content-type": string; 372 | url: string; 373 | } 374 | 375 | export interface BurnMAP extends MAP { 376 | type: "ord"; 377 | op: "burn"; 378 | } 379 | 380 | export type BurnOrdinalsConfig = { 381 | ordPk?: PrivateKey; 382 | ordinals: Utxo[]; 383 | metaData?: BurnMAP; 384 | } 385 | 386 | export type SendOrdinalsConfig = { 387 | paymentUtxos: Utxo[]; 388 | ordinals: Utxo[]; 389 | paymentPk?: PrivateKey; 390 | ordPk?: PrivateKey; 391 | destinations: Destination[]; 392 | changeAddress?: string; 393 | satsPerKb?: number; 394 | metaData?: PreMAP; 395 | signer?: LocalSigner | RemoteSigner; 396 | additionalPayments?: Payment[]; 397 | enforceUniformSend?: boolean; 398 | } 399 | 400 | export type DeployBsv21TokenConfig = { 401 | symbol: string; 402 | decimals?: number; 403 | icon: string | IconInscription; 404 | utxos: Utxo[]; 405 | initialDistribution: Distribution; 406 | paymentPk?: PrivateKey; 407 | destinationAddress: string; 408 | changeAddress?: string; 409 | satsPerKb?: number; 410 | additionalPayments?: Payment[]; 411 | }; 412 | 413 | export type SendUtxosConfig = { 414 | utxos: Utxo[]; 415 | paymentPk?: PrivateKey; 416 | payments: Payment[]; 417 | satsPerKb?: number; 418 | changeAddress?: string; 419 | metaData?: MAP; 420 | }; 421 | 422 | export interface TokenChangeResult extends ChangeResult { 423 | tokenChange?: TokenUtxo[]; 424 | } 425 | 426 | /** 427 | * Configuration object for token outputs 428 | * @typedef {Object} TokenSplitConfig 429 | * @property {number} outputs - Number of outputs to split the token into. Default is 1. 430 | * @property {number} threshold - Optional. Minimum amount of tokens per output. 431 | * @property {boolean} omitMetaData - Set to true to omit metadata from the token change outputs 432 | **/ 433 | export type TokenSplitConfig = { 434 | outputs: number; 435 | threshold?: number; 436 | omitMetaData?: boolean; 437 | } 438 | 439 | export enum TokenInputMode { 440 | All = "all", 441 | Needed = "needed", 442 | } 443 | 444 | /** 445 | * Configuration object for transferring token ordinals 446 | * @typedef {Object} TransferOrdTokensConfig 447 | * @property {TokenType} protocol - Token protocol 448 | * @property {string} tokenID - Token id 449 | * @property {number} decimals - Number of decimal places for this token. 450 | * @property {Utxo[]} utxos - Array of payment Utxos 451 | * @property {TokenUtxo[]} inputTokens - Array of TokenUtxos to be transferred 452 | * @property {Distribution[]} distributions - Array of Distribution objects 453 | * @property {PrivateKey} paymentPk - Private key of the payment address 454 | * @property {PrivateKey} ordPk - Private key of the ord address 455 | * @property {string} [changeAddress] - Optional. Address to send the change 456 | * @property {string} [tokenChangeAddress] - Optional. Address to send the token change 457 | * @property {number} [satsPerKb] - Optional. Satoshis per kilobyte 458 | * @property {PreMAP} [metaData] - Optional. MAP metadata object 459 | * @property {LocalSigner | RemoteSigner} [signer] - Optional. Signer object 460 | * @property {Payment[]} [additionalPayments] - Optional. Array of additional payments 461 | * @property {boolean} [burn] - Optional. Set to true to burn the input tokens 462 | * @property {TokenSplitConfig} [splitConfig] - Optional. Configuration object for splitting token change 463 | * @property {TokenInputMode} [tokenInputMode] - Optional. Token input mode. Default is "needed" 464 | */ 465 | export type TransferOrdTokensConfig = { 466 | protocol: TokenType; 467 | tokenID: string; 468 | decimals: number; 469 | utxos: Utxo[]; 470 | inputTokens: TokenUtxo[]; 471 | distributions: Distribution[]; 472 | paymentPk?: PrivateKey; 473 | ordPk?: PrivateKey; 474 | inputMode?: TokenInputMode; 475 | changeAddress?: string; 476 | tokenChangeAddress?: string; 477 | satsPerKb?: number; 478 | metaData?: PreMAP; 479 | signer?: LocalSigner | RemoteSigner; 480 | additionalPayments?: Payment[]; 481 | burn?: boolean; 482 | splitConfig?: TokenSplitConfig; 483 | tokenInputMode?: TokenInputMode; 484 | } 485 | 486 | export type CreateOrdListingsConfig = { 487 | utxos: Utxo[]; 488 | listings: NewListing[]; 489 | paymentPk?: PrivateKey; 490 | ordPk: PrivateKey, 491 | changeAddress?: string; 492 | satsPerKb?: number; 493 | additionalPayments?: Payment[]; 494 | } 495 | 496 | export type PurchaseOrdListingConfig = { 497 | utxos: Utxo[]; 498 | paymentPk?: PrivateKey; 499 | listing: ExistingListing; 500 | ordAddress: string; 501 | changeAddress?: string; 502 | satsPerKb?: number; 503 | additionalPayments?: Payment[], 504 | royalties?: Royalty[], 505 | metaData?: MAP, 506 | } 507 | 508 | export type PurchaseOrdTokenListingConfig = { 509 | protocol: TokenType; 510 | tokenID: string; 511 | utxos: Utxo[]; 512 | paymentPk?: PrivateKey; 513 | listingUtxo: TokenUtxo; 514 | ordAddress: string; 515 | changeAddress?: string; 516 | satsPerKb?: number; 517 | additionalPayments?: Payment[], 518 | metaData?: MAP, 519 | } 520 | 521 | export type CancelOrdListingsConfig = { 522 | utxos: Utxo[], 523 | paymentPk?: PrivateKey; 524 | ordPk?: PrivateKey; 525 | listingUtxos: Utxo[]; 526 | additionalPayments?: Payment[]; 527 | changeAddress?: string; 528 | satsPerKb?: number; 529 | } 530 | 531 | export interface CancelOrdTokenListingsConfig extends CancelOrdListingsConfig { 532 | utxos: Utxo[], 533 | paymentPk?: PrivateKey; 534 | ordPk?: PrivateKey; 535 | listingUtxos: TokenUtxo[]; 536 | additionalPayments: Payment[]; 537 | changeAddress?: string; 538 | satsPerKb?: number; 539 | protocol: TokenType, 540 | tokenID: string; 541 | ordAddress?: string; 542 | } 543 | 544 | /** 545 | * Configuration object for creating a token listing 546 | * @typedef {Object} CreateOrdTokenListingsConfig 547 | * @property {Utxo[]} utxos - Array of payment Utxos 548 | * @property {TokenUtxo[]} inputTokens - Array of TokenUtxos to be listed 549 | * @property {NewTokenListing[]} listings - Array of NewTokenListings 550 | * @property {PrivateKey} paymentPk - Private key of the payment address 551 | * @property {PrivateKey} ordPk - Private key of the ord address 552 | * @property {string} tokenChangeAddress - Address to send the token change 553 | * @property {number} [satsPerKb] - Optional. Satoshis per kilobyte 554 | * @property {Payment[]} [additionalPayments] - Optional. Array of additional payments 555 | * @property {TokenType} protocol - Token protocol 556 | * @property {string} tokenID - Token id 557 | * @property {number} decimals - Number of decimal places for this token. 558 | */ 559 | export interface CreateOrdTokenListingsConfig { 560 | utxos: Utxo[]; 561 | listings: NewTokenListing[]; 562 | paymentPk?: PrivateKey; 563 | ordPk?: PrivateKey, 564 | changeAddress?: string; 565 | satsPerKb?: number; 566 | additionalPayments?: Payment[]; 567 | protocol: TokenType; 568 | tokenID: string; 569 | decimals: number; 570 | inputTokens: TokenUtxo[]; 571 | tokenChangeAddress: string; 572 | } 573 | 574 | export const MAX_TOKEN_SUPPLY = 2n ** 64n - 1n; -------------------------------------------------------------------------------- /src/utils/broadcast.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BroadcastFailure, 3 | type Broadcaster, 4 | type BroadcastResponse, 5 | type Transaction, 6 | type HttpClient, 7 | Utils, 8 | } from "@bsv/sdk"; 9 | import { API_HOST } from "../constants.js"; 10 | import { defaultHttpClient } from "./httpClient.js"; 11 | 12 | export const oneSatBroadcaster = (): Broadcaster => { 13 | return new OneSatBroadcaster(); 14 | }; 15 | 16 | /** 17 | * Represents a 1Sat API transaction broadcaster. This will broadcast through the 1Sat API. 18 | */ 19 | export default class OneSatBroadcaster implements Broadcaster { 20 | private readonly URL: string; 21 | private readonly httpClient: HttpClient; 22 | 23 | /** 24 | * Constructs an instance of the 1Sat API broadcaster. 25 | * 26 | * @param {HttpClient} httpClient - The HTTP client used to make requests to the API. 27 | */ 28 | constructor( 29 | httpClient: HttpClient = defaultHttpClient(), 30 | ) { 31 | this.URL = `${API_HOST}/tx`; 32 | this.httpClient = httpClient; 33 | } 34 | 35 | /** 36 | * Broadcasts a transaction via WhatsOnChain. 37 | * 38 | * @param {Transaction} tx - The transaction to be broadcasted. 39 | * @returns {Promise} A promise that resolves to either a success or failure response. 40 | */ 41 | async broadcast( 42 | tx: Transaction, 43 | ): Promise { 44 | const rawtx = Utils.toBase64(tx.toBinary()); 45 | 46 | const requestOptions = { 47 | method: "POST", 48 | headers: { 49 | "Content-Type": "application/json", 50 | Accept: "application/json", 51 | }, 52 | data: { rawtx }, 53 | }; 54 | 55 | try { 56 | const response = await this.httpClient.request( 57 | this.URL, 58 | requestOptions, 59 | ); 60 | if (response.ok) { 61 | const txid = response.data; 62 | return { 63 | status: "success", 64 | txid, 65 | message: "broadcast successful", 66 | }; 67 | } 68 | return { 69 | status: "error", 70 | code: response.status.toString() ?? "ERR_UNKNOWN", 71 | description: response.data.message ?? "Unknown error", 72 | }; 73 | } catch (error) { 74 | return { 75 | status: "error", 76 | code: "500", 77 | description: error instanceof Error 78 | ? error.message 79 | : "Internal Server Error", 80 | }; 81 | } 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | 2 | /** fetch function interface limited to options needed by ts-sdk */ 3 | 4 | import type { HttpClient, HttpClientRequestOptions, HttpClientResponse } from "@bsv/sdk" 5 | 6 | /** 7 | * Makes a request to the server. 8 | * @param url The URL to make the request to. 9 | * @param options The request configuration. 10 | */ 11 | export type Fetch = (url: string, options: FetchOptions) => Promise 12 | 13 | /** 14 | * An interface for configuration of the request to be passed to the fetch method 15 | * limited to options needed by ts-sdk. 16 | */ 17 | export interface FetchOptions { 18 | /** A string to set request's method. */ 19 | method?: string 20 | /** An object literal set request's headers. */ 21 | headers?: Record 22 | /** An object or null to set request's body. */ 23 | body?: string | null 24 | } 25 | 26 | /** 27 | * Adapter for Node.js Https module to be used as HttpClient 28 | */ 29 | export class FetchHttpClient implements HttpClient { 30 | constructor (private readonly fetch: Fetch) {} 31 | 32 | async request(url: string, options: HttpClientRequestOptions): Promise> { 33 | const fetchOptions: FetchOptions = { 34 | method: options.method, 35 | headers: options.headers, 36 | body: JSON.stringify(options.data) 37 | } 38 | 39 | const res = await this.fetch.call(window, url, fetchOptions) 40 | const mediaType = res.headers.get('Content-Type') 41 | const data = mediaType?.startsWith('application/json') ? await res.json() : await res.text() 42 | 43 | return { 44 | ok: res.ok, 45 | status: res.status, 46 | statusText: res.statusText, 47 | data: data as D 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/httpClient.ts: -------------------------------------------------------------------------------- 1 | import { type HttpClient, type HttpClientResponse, NodejsHttpClient } from "@bsv/sdk" 2 | import { FetchHttpClient } from "./fetch" 3 | 4 | export function defaultHttpClient (): HttpClient { 5 | const noHttpClient: HttpClient = { 6 | async request (..._): Promise { 7 | throw new Error('No method available to perform HTTP request') 8 | } 9 | } 10 | 11 | if (typeof window !== 'undefined' && typeof window.fetch === 'function') { 12 | const originalFetch = window.fetch 13 | 14 | window.fetch = async (...args) => { 15 | return await originalFetch(...args) 16 | } 17 | 18 | // Use fetch in a browser environment 19 | return new FetchHttpClient(window.fetch) 20 | } 21 | if (typeof require !== 'undefined') { 22 | // Use Node.js https module 23 | try { 24 | const https = require('node:https') 25 | return new NodejsHttpClient(https) 26 | } catch (e) { 27 | return noHttpClient 28 | } 29 | } else { 30 | return noHttpClient 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/icon.ts: -------------------------------------------------------------------------------- 1 | import { imageMeta } from "image-meta"; 2 | import { Utils } from "@bsv/sdk"; 3 | import type { IconInscription, ImageContentType } from "../types"; 4 | const { toArray } = Utils; 5 | 6 | export const ErrorOversizedIcon = new Error( 7 | "Image must be a square image with dimensions <= 400x400", 8 | ); 9 | export const ErrorIconProportions = new Error("Image must be a square image"); 10 | export const ErrorInvalidIconData = new Error("Error processing image"); 11 | export const ErrorImageDimensionsUndefined = new Error( 12 | "Image dimensions are undefined", 13 | ); 14 | 15 | const isImageContentType = (value: string): value is ImageContentType => { 16 | return (value as ImageContentType) === value; 17 | }; 18 | 19 | export const validIconData = async ( 20 | icon: IconInscription, 21 | ): Promise => { 22 | const { dataB64, contentType } = icon; 23 | 24 | if (contentType === "image/svg+xml") { 25 | return validateSvg(dataB64); 26 | } 27 | 28 | if (!isImageContentType(contentType)) { 29 | return ErrorInvalidIconData; 30 | } 31 | 32 | try { 33 | const buffer = Uint8Array.from(toArray(dataB64, "base64")); 34 | 35 | // Meta contains { type, width?, height?, orientation? } 36 | const dimensions = imageMeta(buffer); 37 | 38 | if (dimensions.width === undefined || dimensions.height === undefined) { 39 | return ErrorImageDimensionsUndefined; 40 | } 41 | if (dimensions.width !== dimensions.height) { 42 | return ErrorIconProportions; 43 | } 44 | if (dimensions.width > 400 || dimensions.height > 400) { 45 | return ErrorOversizedIcon; 46 | } 47 | 48 | return null; 49 | } catch (error) { 50 | return ErrorInvalidIconData; 51 | } 52 | }; 53 | 54 | const validateSvg = (svgBase64: string): Error | null => { 55 | const svgString = Buffer.from(svgBase64, "base64").toString("utf-8"); 56 | const widthMatch = svgString.match(/]*\s+width="([^"]+)"/); 57 | const heightMatch = svgString.match(/]*\s+height="([^"]+)"/); 58 | 59 | if (!widthMatch || !heightMatch) { 60 | return ErrorImageDimensionsUndefined; 61 | } 62 | 63 | const width = Number.parseInt(widthMatch[1], 10); 64 | const height = Number.parseInt(heightMatch[1], 10); 65 | 66 | if (Number.isNaN(width) || Number.isNaN(height)) { 67 | return ErrorImageDimensionsUndefined; 68 | } 69 | 70 | if (width !== height) { 71 | return ErrorIconProportions; 72 | } 73 | if (width > 400 || height > 400) { 74 | return ErrorOversizedIcon; 75 | } 76 | 77 | return null; 78 | } 79 | 80 | export const validIconFormat = (icon: string): boolean => { 81 | if (!icon.includes("_") || icon.endsWith("_")) { 82 | return false; 83 | } 84 | 85 | const iconVout = Number.parseInt(icon.split("_")[1]); 86 | if (Number.isNaN(iconVout)) { 87 | return false; 88 | } 89 | 90 | if (!icon.startsWith("_") && icon.split("_")[0].length !== 64) { 91 | return false; 92 | } 93 | 94 | return true; 95 | }; 96 | 97 | -------------------------------------------------------------------------------- /src/utils/paymail.ts: -------------------------------------------------------------------------------- 1 | // import { PaymailClient } from "@bsv/paymail"; 2 | import { LockingScript } from "@bsv/sdk"; 3 | 4 | // const client = new PaymailClient(); 5 | 6 | export const resolvePaymail = async (paymailAddress: string, amtToReceive: number): Promise => { 7 | // const destinationTx = await client.getP2pPaymentDestination(paymailAddress, amtToReceive); 8 | // // TODO: we are assuming only one output but in reality it can be many 9 | // return destinationTx.outputs[0].script as LockingScript; 10 | throw new Error("Not implemented"); 11 | } -------------------------------------------------------------------------------- /src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a string to its hexadecimal representation 3 | * 4 | * @param {string} utf8Str - The string to convert 5 | * @returns {string} The hexadecimal representation of the input string 6 | */ 7 | const toHex = (utf8Str: string): string => { 8 | return Buffer.from(utf8Str).toString("hex"); 9 | }; 10 | 11 | export { toHex }; 12 | -------------------------------------------------------------------------------- /src/utils/subtypeData.ts: -------------------------------------------------------------------------------- 1 | import type { MAP, PreMAP } from "../types"; 2 | 3 | const stringifyMetaData = (metaData?: PreMAP): MAP | undefined => { 4 | if (!metaData) return undefined; 5 | const result: MAP = { 6 | app: metaData.app, 7 | type: metaData.type, 8 | }; 9 | 10 | for (const [key, value] of Object.entries(metaData)) { 11 | if (value !== undefined) { 12 | if (typeof value === "string") { 13 | result[key] = value; 14 | } else if (Array.isArray(value) || typeof value === "object") { 15 | result[key] = JSON.stringify(value); 16 | } else { 17 | result[key] = String(value); 18 | } 19 | } 20 | } 21 | 22 | return result; 23 | }; 24 | 25 | export default stringifyMetaData; 26 | -------------------------------------------------------------------------------- /src/utils/utxo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "bun:test"; 2 | import { TokenSelectionStrategy, type TokenUtxo } from '../types' 3 | import { selectTokenUtxos } from './utxo'; 4 | 5 | describe('selectTokenUtxos', () => { 6 | const mockUtxos: TokenUtxo[] = [ 7 | { amt: '100', id: 'token1', satoshis: 1, txid: 'tx1', vout: 0, script: 'script1' }, 8 | { amt: '200', id: 'token1', satoshis: 1, txid: 'tx2', vout: 1, script: 'script2' }, 9 | { amt: '300', id: 'token1', satoshis: 1, txid: 'tx3', vout: 2, script: 'script3' }, 10 | { amt: '400', id: 'token1', satoshis: 1, txid: 'tx4', vout: 3, script: 'script4' }, 11 | { amt: '500', id: 'token1', satoshis: 1, txid: 'tx5', vout: 4, script: 'script5' }, 12 | ]; 13 | 14 | it('should select UTXOs with RetainOrder strategy for input and output (default)', () => { 15 | const result = selectTokenUtxos(mockUtxos, 5.5, 2); 16 | expect(result.selectedUtxos).toEqual(mockUtxos.slice(0, 3)); 17 | expect(result.totalSelected).toBe(6); 18 | expect(result.isEnough).toBe(true); 19 | }); 20 | 21 | it('should sort output UTXOs with SmallestFirst output strategy', () => { 22 | const result = selectTokenUtxos(mockUtxos, 10, 2, { outputStrategy: TokenSelectionStrategy.SmallestFirst }); 23 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['100', '200', '300', '400']); 24 | expect(result.totalSelected).toBe(10); 25 | expect(result.isEnough).toBe(true); 26 | }); 27 | 28 | it('should sort output UTXOs with LargestFirst output strategy', () => { 29 | const result = selectTokenUtxos(mockUtxos, 10, 2, { outputStrategy: TokenSelectionStrategy.LargestFirst }); 30 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['400', '300', '200', '100']); 31 | expect(result.totalSelected).toBe(10); 32 | expect(result.isEnough).toBe(true); 33 | }); 34 | 35 | it('should sort output UTXOs with SmallestFirst output strategy', () => { 36 | const result = selectTokenUtxos(mockUtxos, 10, 2, { outputStrategy: TokenSelectionStrategy.SmallestFirst }); 37 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['100', '200', '300', '400']); 38 | expect(result.totalSelected).toBe(10); 39 | expect(result.isEnough).toBe(true); 40 | }); 41 | 42 | it('should sort output UTXOs with LargestFirst input strategy', () => { 43 | const result = selectTokenUtxos(mockUtxos, 10, 2, { inputStrategy: TokenSelectionStrategy.LargestFirst }); 44 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['500', '400', '300']); 45 | expect(result.totalSelected).toBe(12); 46 | expect(result.isEnough).toBe(true); 47 | }); 48 | 49 | it('should handle case when not enough UTXOs are available', () => { 50 | const result = selectTokenUtxos(mockUtxos, 20, 2); 51 | expect(result.selectedUtxos).toEqual(mockUtxos); 52 | expect(result.totalSelected).toBe(15); 53 | expect(result.isEnough).toBe(false); 54 | }); 55 | 56 | it('should handle empty UTXO array', () => { 57 | const result = selectTokenUtxos([], 5, 2); 58 | expect(result.selectedUtxos).toEqual([]); 59 | expect(result.totalSelected).toBe(0); 60 | expect(result.isEnough).toBe(false); 61 | }); 62 | 63 | it('should handle zero required amount', () => { 64 | const result = selectTokenUtxos(mockUtxos, 0, 2); 65 | expect(result.selectedUtxos).toEqual(mockUtxos); 66 | expect(result.totalSelected).toBe(15); 67 | expect(result.isEnough).toBe(true); 68 | }); 69 | 70 | it('should handle different decimal places', () => { 71 | const result = selectTokenUtxos(mockUtxos, 0.000003, 6); 72 | expect(result.selectedUtxos).toEqual([mockUtxos[0]]); 73 | expect(result.totalSelected).toBe(0.0001); 74 | expect(result.isEnough).toBe(true); 75 | }); 76 | 77 | it('should handle Random input strategy', () => { 78 | const result = selectTokenUtxos(mockUtxos, 5.5, 2, { inputStrategy: TokenSelectionStrategy.Random }); 79 | expect(result.selectedUtxos.length).toBeGreaterThan(0); 80 | expect(result.totalSelected).toBeGreaterThanOrEqual(5.5); 81 | expect(result.isEnough).toBe(true); 82 | }); 83 | 84 | it('should handle Random output strategy', () => { 85 | const result = selectTokenUtxos(mockUtxos, 10, 2, { outputStrategy: TokenSelectionStrategy.Random }); 86 | expect(result.selectedUtxos.length).toBe(4); 87 | expect(result.totalSelected).toBe(10); 88 | expect(result.isEnough).toBe(true); 89 | }); 90 | 91 | it('should use SmallestFirst input strategy and LargestFirst output strategy', () => { 92 | const result = selectTokenUtxos(mockUtxos, 6, 2, { 93 | inputStrategy: TokenSelectionStrategy.SmallestFirst, 94 | outputStrategy: TokenSelectionStrategy.LargestFirst 95 | }); 96 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['300', '200', '100']); 97 | expect(result.totalSelected).toBe(6); 98 | expect(result.isEnough).toBe(true); 99 | }); 100 | 101 | it('should use LargestFirst input strategy and SmallestFirst output strategy', () => { 102 | const result = selectTokenUtxos(mockUtxos, 7, 2, { 103 | inputStrategy: TokenSelectionStrategy.LargestFirst, 104 | outputStrategy: TokenSelectionStrategy.SmallestFirst 105 | }); 106 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['400', '500']); 107 | expect(result.totalSelected).toBe(9); 108 | expect(result.isEnough).toBe(true); 109 | }); 110 | 111 | it('should use Random input strategy and LargestFirst output strategy', () => { 112 | const result = selectTokenUtxos(mockUtxos, 7, 2, { 113 | inputStrategy: TokenSelectionStrategy.Random, 114 | outputStrategy: TokenSelectionStrategy.LargestFirst 115 | }); 116 | expect(result.selectedUtxos.length).toBeGreaterThan(0); 117 | expect(result.totalSelected).toBeGreaterThanOrEqual(7); 118 | expect(result.isEnough).toBe(true); 119 | expect(result.selectedUtxos).toEqual(result.selectedUtxos.sort((a, b) => Number(BigInt(b.amt) - BigInt(a.amt)))); 120 | }); 121 | 122 | it('should use SmallestFirst input strategy and Random output strategy', () => { 123 | const result = selectTokenUtxos(mockUtxos, 7, 2, { 124 | inputStrategy: TokenSelectionStrategy.SmallestFirst, 125 | outputStrategy: TokenSelectionStrategy.Random 126 | }); 127 | const inputOrder = ['100', '200', '300', '400']; 128 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(expect.arrayContaining(inputOrder)); 129 | expect(result.totalSelected).toBe(10); 130 | expect(result.isEnough).toBe(true); 131 | }); 132 | 133 | it('should handle edge case with SmallestFirst input and LargestFirst output when exact amount is reached', () => { 134 | const result = selectTokenUtxos(mockUtxos, 6, 2, { 135 | inputStrategy: TokenSelectionStrategy.SmallestFirst, 136 | outputStrategy: TokenSelectionStrategy.LargestFirst 137 | }); 138 | expect(result.selectedUtxos.map(u => u.amt)).toEqual(['300', '200', '100']); 139 | expect(result.totalSelected).toBe(6); 140 | expect(result.isEnough).toBe(true); 141 | }); 142 | }); -------------------------------------------------------------------------------- /src/utils/utxo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Transaction, 3 | type UnlockingScript, 4 | fromUtxo, 5 | type TransactionInput, 6 | Utils, 7 | P2PKH, 8 | Script, 9 | } from "@bsv/sdk"; 10 | import { type NftUtxo, type TokenSelectionOptions, type TokenSelectionResult, TokenSelectionStrategy, TokenType, type TokenUtxo, type Utxo } from "../types"; 11 | import { API_HOST } from "../constants"; 12 | import { toToken } from "satoshi-token"; 13 | 14 | const { fromBase58Check } = Utils; 15 | 16 | /** 17 | * Converts a Utxo object with a base64 encoded script to a Utxo object with a hex encoded script 18 | * @param {Utxo} utxo - Utxo object with base64 encoded script 19 | * @param {Object} unlockScriptTemplate - Object with sign and estimateLength functions 20 | * @returns {TransactionInput} Utxo object with hex encoded script 21 | */ 22 | export const inputFromB64Utxo = ( 23 | utxo: Utxo, 24 | unlockScriptTemplate: { 25 | sign: (tx: Transaction, inputIndex: number) => Promise; 26 | estimateLength: (tx: Transaction, inputIndex: number) => Promise; 27 | }, 28 | ): TransactionInput => { 29 | const input = fromUtxo( 30 | { 31 | ...utxo, 32 | script: Buffer.from(utxo.script, "base64").toString("hex"), 33 | }, 34 | unlockScriptTemplate, 35 | ); 36 | return input; 37 | }; 38 | 39 | /** 40 | * Fetches pay utxos from the API 41 | * @param {string} address - Address to fetch utxos for 42 | * @returns {Promise} Array of pay utxos 43 | */ 44 | export const fetchPayUtxos = async (address: string, scriptEncoding: "hex" | "base64" | "asm" = "base64"): Promise => { 45 | const payUrl = `${API_HOST}/txos/address/${address}/unspent?bsv20=false`; 46 | const payRes = await fetch(payUrl); 47 | if (!payRes.ok) { 48 | throw new Error("Error fetching pay utxos"); 49 | } 50 | let payUtxos = await payRes.json(); 51 | // exclude all 1 satoshi utxos 52 | payUtxos = payUtxos.filter((u: Utxo) => u.satoshis !== 1 && !isLock(u)); 53 | 54 | // Get pubkey hash from address 55 | const pubKeyHash = fromBase58Check(address); 56 | const p2pkhScript = new P2PKH().lock(pubKeyHash.data); 57 | payUtxos = payUtxos.map((utxo: Partial) => ({ 58 | txid: utxo.txid, 59 | vout: utxo.vout, 60 | satoshis: utxo.satoshis, 61 | script: scriptEncoding === "hex" || scriptEncoding === "base64" ? Buffer.from(p2pkhScript.toBinary()).toString(scriptEncoding) : p2pkhScript.toASM(), 62 | })); 63 | return payUtxos as Utxo[]; 64 | }; 65 | 66 | /** 67 | * Fetches NFT utxos from the API 68 | * @param {string} address - Address to fetch utxos for 69 | * @param {string} [collectionId] - Optional. Collection id (collection insciprtion origin) 70 | * @param {number} [limit=10] - Optional. Number of utxos to fetch. Default is 10 71 | * @param {number} [offset=0] - Optional. Offset for fetching utxos. Default is 0 72 | * @param {string} [scriptEncoding="base64"] - Optional. Encoding for the script. Default is base64. Options are hex, base64, or asm. 73 | * @returns {Promise} Array of NFT utxos 74 | */ 75 | export const fetchNftUtxos = async ( 76 | address: string, 77 | collectionId?: string, 78 | limit = 10, 79 | offset = 0, 80 | scriptEncoding: "hex" | "base64" | "asm" = "base64", 81 | ): Promise => { 82 | let url = `${API_HOST}/txos/address/${address}/unspent?limit=${limit}&offset=${offset}&`; 83 | 84 | if (collectionId) { 85 | const query = { 86 | map: { 87 | subTypeData: { collectionId }, 88 | }, 89 | }; 90 | const b64Query = Buffer.from(JSON.stringify(query)).toString("base64"); 91 | url += `q=${b64Query}`; 92 | } 93 | 94 | const res = await fetch(url); 95 | if (!res.ok) { 96 | throw new Error(`Error fetching NFT utxos for ${address}`); 97 | } 98 | 99 | // Returns a BSV20Txo but we only need a few fields 100 | let nftUtxos = await res.json(); 101 | 102 | // Only include 1 satoshi outputs, non listings 103 | nftUtxos = nftUtxos.filter( 104 | (u: { 105 | satoshis: number; 106 | data: { list: { price: number; payout: string } | undefined } | null; 107 | }) => u.satoshis === 1 && !u.data?.list, 108 | ); 109 | 110 | const outpoints = nftUtxos.map( 111 | (utxo: { txid: string; vout: number }) => `${utxo.txid}_${utxo.vout}`, 112 | ); 113 | // Fetch the scripts up to the limit 114 | const nftRes = await fetch(`${API_HOST}/txos/outpoints?script=true`, { 115 | method: "POST", 116 | headers: { 117 | "Content-Type": "application/json", 118 | }, 119 | body: JSON.stringify([...outpoints]), 120 | }); 121 | 122 | if (!nftRes.ok) { 123 | throw new Error(`Error fetching NFT scripts for ${address}`); 124 | } 125 | 126 | const nfts = (await nftRes.json() || []) 127 | 128 | nftUtxos = nfts.map( 129 | (utxo: { 130 | origin: { outpoint: string }; 131 | script: string; 132 | vout: number; 133 | txid: string; 134 | }) => { 135 | let script = utxo.script; 136 | if (scriptEncoding === "hex") { 137 | script = Buffer.from(script, "base64").toString("hex"); 138 | } else if (scriptEncoding === "asm") { 139 | script = Script.fromHex(Buffer.from(script, "base64").toString("hex")).toASM(); 140 | } 141 | const nftUtxo = { 142 | origin: utxo.origin.outpoint, 143 | script, 144 | vout: utxo.vout, 145 | txid: utxo.txid, 146 | satoshis: 1, 147 | } as NftUtxo; 148 | if (collectionId) { 149 | nftUtxo.collectionId = collectionId; 150 | } 151 | return nftUtxo; 152 | }, 153 | ); 154 | 155 | return nftUtxos as NftUtxo[]; 156 | }; 157 | 158 | /** 159 | * Fetches token utxos from the API 160 | * @param {TokenType} protocol - Token protocol. Either BSV20 or BSV21 161 | * @param {string} tokenId - Token id. Ticker for BSV20 and id (mint+deploy inscription origin) for BSV21 162 | * @param {string} address - Address to fetch utxos for 163 | * @param {number} [limit=10] - Number of utxos to fetch. Default is 10 164 | * @param {number} [offset=0] - Offset for fetching utxos. Default is 0 165 | * @returns {Promise} Array of token utxos 166 | */ 167 | export const fetchTokenUtxos = async ( 168 | protocol: TokenType, 169 | tokenId: string, 170 | address: string, 171 | limit = 10, 172 | offset = 0, 173 | ): Promise => { 174 | const url = `${API_HOST}/bsv20/${address}/${protocol === TokenType.BSV20 ? "tick" : "id"}/${tokenId}?bsv20=true&listing=false&limit=${limit}&offset=${offset}`; 175 | const res = await fetch(url); 176 | if (!res.ok) { 177 | throw new Error(`Error fetching ${protocol} utxos`); 178 | } 179 | 180 | // returns a BSV20Txo but we only need a few fields 181 | let tokenUtxos = await res.json(); 182 | 183 | tokenUtxos = tokenUtxos.map((utxo: Partial) => ({ 184 | amt: utxo.amt, 185 | script: utxo.script, 186 | vout: utxo.vout, 187 | txid: utxo.txid, 188 | id: tokenId, 189 | satoshis: 1, 190 | })); 191 | 192 | return tokenUtxos as TokenUtxo[]; 193 | }; 194 | 195 | const isLock = (utxo: Utxo) => { 196 | return !!(utxo as unknown as { data?: {lock: { address: string, until: number } }}).data?.lock; 197 | } 198 | 199 | /** 200 | * Selects token UTXOs based on the required amount and specified strategies. 201 | * @param {TokenUtxo[]} tokenUtxos - Array of token UTXOs. 202 | * @param {number} requiredTokens - Required amount in tokens (displayed amount). 203 | * @param {number} decimals - Number of decimal places for the token. 204 | * @param {TokenSelectionOptions} [options={}] - Options for token selection. 205 | * @returns {TokenSelectionResult} Selected token UTXOs and total selected amount. 206 | */ 207 | export const selectTokenUtxos = ( 208 | tokenUtxos: TokenUtxo[], 209 | requiredTokens: number, 210 | decimals: number, 211 | options: TokenSelectionOptions = {} 212 | ): TokenSelectionResult => { 213 | const { 214 | inputStrategy = TokenSelectionStrategy.RetainOrder, 215 | outputStrategy = TokenSelectionStrategy.RetainOrder, 216 | } = options; 217 | 218 | // Sort the UTXOs based on the input strategy 219 | const sortedUtxos = [...tokenUtxos].sort((a, b) => { 220 | if (inputStrategy === TokenSelectionStrategy.RetainOrder) return 0; 221 | const amtA = BigInt(a.amt); 222 | const amtB = BigInt(b.amt); 223 | 224 | switch (inputStrategy) { 225 | case TokenSelectionStrategy.SmallestFirst: 226 | return Number(amtA - amtB); 227 | case TokenSelectionStrategy.LargestFirst: 228 | return Number(amtB - amtA); 229 | case TokenSelectionStrategy.Random: 230 | return Math.random() - 0.5; 231 | default: 232 | return 0; 233 | } 234 | }); 235 | 236 | let totalSelected = 0; 237 | const selectedUtxos: TokenUtxo[] = []; 238 | 239 | for (const utxo of sortedUtxos) { 240 | selectedUtxos.push(utxo); 241 | totalSelected += toToken(utxo.amt, decimals); 242 | if (totalSelected >= requiredTokens && requiredTokens > 0) { 243 | break; 244 | } 245 | } 246 | 247 | // Sort the selected UTXOs based on the output strategy 248 | if (outputStrategy !== TokenSelectionStrategy.RetainOrder) { 249 | selectedUtxos.sort((a, b) => { 250 | const amtA = BigInt(a.amt); 251 | const amtB = BigInt(b.amt); 252 | 253 | switch (outputStrategy) { 254 | case TokenSelectionStrategy.SmallestFirst: 255 | return Number(amtA - amtB); 256 | case TokenSelectionStrategy.LargestFirst: 257 | return Number(amtB - amtA); 258 | case TokenSelectionStrategy.Random: 259 | return Math.random() - 0.5; 260 | default: 261 | return 0; 262 | } 263 | }); 264 | } 265 | 266 | return { 267 | selectedUtxos, 268 | totalSelected, 269 | isEnough: totalSelected >= requiredTokens 270 | }; 271 | }; -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionItemSubTypeData, CollectionSubTypeData } from "./types"; 2 | 3 | /** 4 | * Validates sub type data 5 | * @param {string} subType - Sub type of the ordinals token 6 | * @param {string} subTypeData - Sub type data of the ordinals token 7 | * @returns {Error | undefined} Error if validation fails, undefined if validation passes 8 | */ 9 | export const validateSubTypeData = ( 10 | subType: "collection" | "collectionItem", 11 | subTypeData: CollectionItemSubTypeData | CollectionSubTypeData, 12 | ): Error | undefined => { 13 | try { 14 | if (subType === "collection") { 15 | const collectionData = subTypeData as CollectionSubTypeData; 16 | if (!collectionData.description) { 17 | return new Error("Collection description is required"); 18 | } 19 | if (!collectionData.quantity) { 20 | return new Error("Collection quantity is required"); 21 | } 22 | if (collectionData.rarityLabels) { 23 | if (!Array.isArray(collectionData.rarityLabels)) { 24 | return new Error("Rarity labels must be an array"); 25 | } 26 | // make sure keys and values are strings 27 | if (!collectionData.rarityLabels.every((label) => { 28 | return Object.values(label).every(value => typeof value === 'string'); 29 | })) { 30 | return new Error(`Invalid rarity labels ${collectionData.rarityLabels}`); 31 | } 32 | } 33 | if (collectionData.traits ) { 34 | if (typeof collectionData.traits !== "object") { 35 | return new Error("Collection traits must be an object"); 36 | } 37 | if (collectionData.traits && !Object.keys(collectionData.traits).every(key => typeof key === 'string' && typeof collectionData.traits[key] === 'object')) { 38 | return new Error("Collection traits must be a valid CollectionTraits object"); 39 | } 40 | } 41 | } 42 | if (subType === "collectionItem") { 43 | const itemData = subTypeData as CollectionItemSubTypeData; 44 | if (!itemData.collectionId) { 45 | return new Error("Collection id is required"); 46 | } 47 | if (!itemData.collectionId.includes("_")) { 48 | return new Error("Collection id must be a valid outpoint"); 49 | } 50 | if (itemData.collectionId.split("_")[0].length !== 64) { 51 | return new Error("Collection id must contain a valid txid"); 52 | } 53 | if (Number.isNaN(Number.parseInt(itemData.collectionId.split("_")[1]))) { 54 | return new Error("Collection id must contain a valid vout"); 55 | } 56 | 57 | if (itemData.mintNumber && typeof itemData.mintNumber !== "number") { 58 | return new Error("Mint number must be a number"); 59 | } 60 | if (itemData.rank && typeof itemData.rank !== "number") { 61 | return new Error("Rank must be a number"); 62 | } 63 | if (itemData.rarityLabel && typeof itemData.rarityLabel !== "string") { 64 | return new Error("Rarity label must be a string"); 65 | } 66 | if (itemData.traits && typeof itemData.traits !== "object") { 67 | return new Error("Traits must be an object"); 68 | } 69 | if (itemData.attachments && !Array.isArray(itemData.attachments)) { 70 | return new Error("Attachments must be an array"); 71 | } 72 | } 73 | return undefined; 74 | } catch (error) { 75 | return new Error("Invalid JSON data"); 76 | } 77 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "noEmit": false, 8 | "strict": true, 9 | "moduleResolution": "Bundler", 10 | "typeRoots": [ 11 | "./node_modules/@types", 12 | "./types" 13 | ], 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | }, 18 | "exclude": ["node_modules", "./dist", "src/**/*.test.ts"], 19 | "include": ["src/**/*"], 20 | } --------------------------------------------------------------------------------