├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── bin └── cat721.js ├── config.example.json ├── logo.png ├── mocks.config.js ├── mocks └── routes │ └── tokens.js ├── nest-cli.json ├── package.json ├── resource ├── 0.png ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── 6.png ├── 7.png ├── 8.png └── 9.png ├── scripts ├── metadata.ts └── postinstall.js ├── src ├── app.module.ts ├── collection │ ├── collection.ts │ └── index.ts ├── commands │ ├── base.command.ts │ ├── boardcast.command.ts │ ├── deploy │ │ ├── deploy.command.ts │ │ ├── nft.closed-mint.ts │ │ ├── nft.open-mint.ts │ │ └── nft.parallel-closed-mint.ts │ ├── mint │ │ ├── mint.command.ts │ │ ├── nft.closed-mint.ts │ │ ├── nft.open-mint.ts │ │ ├── nft.parallel-closed-mint.ts │ │ ├── nft.ts │ │ └── split.ts │ ├── send │ │ ├── nft.ts │ │ ├── pick.ts │ │ └── send.command.ts │ ├── version.command.ts │ └── wallet │ │ ├── address.command.ts │ │ ├── balance.command.ts │ │ ├── create.command.ts │ │ ├── import.command.ts │ │ ├── table.ts │ │ └── wallet.command.ts ├── common │ ├── apis-rpc.ts │ ├── apis-tracker.ts │ ├── apis.ts │ ├── btc.ts │ ├── cli-config.ts │ ├── contact.ts │ ├── index.ts │ ├── log.ts │ ├── metadata.ts │ ├── minter.ts │ ├── minterFinder.ts │ ├── postage.ts │ ├── utils.ts │ └── wallet.ts ├── main.ts └── providers │ ├── configService.ts │ ├── index.ts │ ├── spendService.ts │ └── walletService.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | 58 | config.json 59 | wallet.json 60 | wallet*.json 61 | spends.json 62 | metadata.json 63 | metadata*.json 64 | .npmrc 65 | collections.json 66 | tokens.json 67 | *.txt -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src/ 3 | mocks/ 4 | loopmint.js 5 | mocks.config.js 6 | nest-cli.json 7 | .npmrc 8 | spends.json 9 | tokens.json 10 | wallet.json 11 | wallet*.json 12 | config.json 13 | config*.json 14 | metadata.json 15 | metadata*.json 16 | tsconfig.build.json 17 | .eslintrc.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CAT CLI 2 | 3 | `cli` requires a synced [tracker](https://github.com/CATProtocol/cat-token-box/blob/main/packages/tracker/README.md), run it from the beginning or follow this [guide](https://github.com/CATProtocol/cat-token-box/releases/tag/cat721) to upgrade. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | yarn install 9 | ``` 10 | 11 | ## Build 12 | 13 | ```sh 14 | yarn build 15 | ``` 16 | 17 | ## Usage 18 | 19 | 1. Copy [config.example.json](config.example.json) as `config.json`. Update `config.json` with your own configuration. 20 | 21 | All commands use the `config.json` in the current working directory by default. You can also specify a customized configuration file with `--config=your.json`. 22 | 23 | 2. Create a wallet 24 | 25 | ```bash 26 | yarn cli wallet create 27 | ``` 28 | 29 | You should see an output similar to: 30 | 31 | ``` 32 | ? What is the mnemonic value of your account? (default: generate a new mnemonic) ******** 33 | Your wallet mnemonic is: ******** 34 | exporting address to the RPC node ... 35 | successfully. 36 | ``` 37 | 38 | 3. Show address 39 | 40 | ```bash 41 | yarn cli wallet address 42 | ``` 43 | 44 | You should see an output similar to: 45 | 46 | ``` 47 | Your address is bc1plfkwa8r7tt8vvtwu0wgy2m70d6cs7gwswtls0shpv9vn6h4qe7gqjjjf86 48 | ``` 49 | 50 | 4. Fund your address 51 | 52 | Deposit some satoshis to your address. 53 | 54 | 55 | 5. Show nfts 56 | 57 | ```bash 58 | yarn cli wallet balances -i c1a1a777a52f765ebfa295a35c12280279edd46073d41f4767602f819f574f82_0 59 | ``` 60 | 61 | You should see an output similar to: 62 | 63 | ``` 64 | ┌────────────────────────────────────────────────────────────────────────┬────────┐ 65 | │ nft │ symbol │ 66 | ┼────────────────────────────────────────────────────────────────────────┼────────┤ 67 | │ 'c1a1a777a52f765ebfa295a35c12280279edd46073d41f4767602f819f574f82_0:1' │ 'LCAT' │ 68 | │ 'c1a1a777a52f765ebfa295a35c12280279edd46073d41f4767602f819f574f82_0:0' │ 'LCAT' │ 69 | ┴────────────────────────────────────────────────────────────────────────┴────────┘ 70 | ``` 71 | 72 | 6. Deploy a collection 73 | 74 | - deploy with a metadata json: 75 | 76 | 77 | ```bash 78 | yarn cli deploy --metadata=metadata.json 79 | ``` 80 | 81 | `metadata.json`: 82 | 83 | - closed mint: 84 | 85 | 86 | ```json 87 | { 88 | "name": "LCAT", 89 | "symbol": "LCAT", 90 | "description": "this is a cat721 nft collection", 91 | "max": "10" 92 | } 93 | ``` 94 | 95 | - open mint: 96 | 97 | 98 | ```json 99 | { 100 | "name": "LCAT", 101 | "symbol": "LCAT", 102 | "description": "this is a cat721 nft collection", 103 | "premine": "0", 104 | "max": "10" 105 | } 106 | ``` 107 | 108 | - deploy with command line options: 109 | 110 | 111 | - closed mint 112 | 113 | ```bash 114 | yarn cli deploy --name=LCAT --symbol=LCAT --max=10 115 | ``` 116 | 117 | - parallel closed mint: 118 | 119 | ```bash 120 | yarn cli deploy --name=LCAT --symbol=LCAT --max=10 --parallel 121 | ``` 122 | 123 | - open mint 124 | 125 | 126 | ```bash 127 | yarn cli deploy --name=LCAT --symbol=LCAT --max=10 --premine=0 --openMint 128 | ``` 129 | 130 | You should see an output similar to: 131 | 132 | ``` 133 | Nft collection LCAT has been deployed. 134 | CollectionId: c1a1a777a52f765ebfa295a35c12280279edd46073d41f4767602f819f574f82_0 135 | Genesis txid: c1a1a777a52f765ebfa295a35c12280279edd46073d41f4767602f819f574f82 136 | Reveal txid: d7871b55f88545e0fb4df2a793fea77d0717a7bb7cab3d22a59b72ddb5b51265 137 | ``` 138 | 139 | 140 | 1. Mint nft 141 | 142 | ```bash 143 | yarn cli mint -i [collectionId] 144 | ``` 145 | You should see an output similar to: 146 | 147 | ``` 148 | Minting LCAT NFT in txid: ef9d98eeae21c6bd8aa172cc1d78d9e4e3749a7632e4119f2f2484396f95f5cb ... 149 | ``` 150 | 151 | 1. Send nft 152 | 153 | ```bash 154 | yarn cli send -i [collectionId] -l [localId] [receiver] 155 | ``` 156 | You should see an output similar to: 157 | 158 | ``` 159 | Sending LCAT:0 nft to bc1ppresfm876y9ddn3fgw2zr0wj0pl3zanslje9nfpznq3kc90q46rqvm9k07 160 | in txid: 277eb0198b4fed9a03845d279cf58fc3289e8a68abdd36981381accb8c24ef52 161 | ``` 162 | 163 | ----------------- 164 | 165 | ### FeeRate 166 | 167 | `deploy`, `mint`, and `send` commands can all specify a fee rate via option `--fee-rate`. 168 | -------------------------------------------------------------------------------- /bin/cat721.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { bootstrap } = require('../dist/main'); 3 | bootstrap(); 4 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": "fractal-mainnet", 3 | "tracker": "http://127.0.0.1:3000", 4 | "dataDir": ".", 5 | "maxFeeRate": 3, 6 | "rpc": { 7 | "url": "http://127.0.0.1:8332", 8 | "username": "bitcoin", 9 | "password": "opcatAwesome" 10 | } 11 | } -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/logo.png -------------------------------------------------------------------------------- /mocks.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://www.mocks-server.org/docs/configuration/how-to-change-settings 3 | // https://www.mocks-server.org/docs/configuration/options 4 | 5 | module.exports = { 6 | // Log level. Can be one of silly, debug, verbose, info, warn or error 7 | //log: "info", 8 | config: { 9 | // Allow unknown arguments 10 | //allowUnknownArguments: false, 11 | }, 12 | plugins: { 13 | // Plugins to be registered 14 | //register: [], 15 | proxyRoutesHandler: {}, 16 | adminApi: { 17 | // Port number for the admin API server to be listening at 18 | //port: 3110, 19 | // Host for the admin API server 20 | //host: "0.0.0.0", 21 | https: { 22 | // Use https protocol or not 23 | //enabled: false, 24 | // Path to a TLS/SSL certificate 25 | //cert: undefined, 26 | // Path to the certificate private key 27 | //key: undefined, 28 | }, 29 | }, 30 | inquirerCli: { 31 | // Start interactive CLI or not 32 | //enabled: true, 33 | // Render emojis or not 34 | //emojis: true, 35 | }, 36 | openapi: { 37 | collection: { 38 | // Name for the collection created from OpenAPI definitions 39 | //id: "openapi", 40 | // Name of the collection to extend from 41 | //from: undefined, 42 | }, 43 | }, 44 | }, 45 | mock: { 46 | routes: { 47 | // Global delay to apply to routes 48 | //delay: 0, 49 | }, 50 | collections: { 51 | // Selected collection 52 | selected: 'cat-token', 53 | }, 54 | }, 55 | server: { 56 | // Port number for the server to be listening at 57 | //port: 3100, 58 | // Host for the server 59 | //host: "0.0.0.0", 60 | cors: { 61 | // Use CORS middleware or not 62 | //enabled: true, 63 | // Options for the CORS middleware. Further information at https://github.com/expressjs/cors#configuration-options 64 | //options: {"preflightContinue":false}, 65 | }, 66 | jsonBodyParser: { 67 | // Use json body-parser middleware or not 68 | //enabled: true, 69 | // Options for the json body-parser middleware. Further information at https://github.com/expressjs/body-parser 70 | //options: {}, 71 | }, 72 | urlEncodedBodyParser: { 73 | // Use urlencoded body-parser middleware or not 74 | //enabled: true, 75 | // Options for the urlencoded body-parser middleware. Further information at https://github.com/expressjs/body-parser 76 | //options: {"extended":true}, 77 | }, 78 | https: { 79 | // Use https protocol or not 80 | //enabled: false, 81 | // Path to a TLS/SSL certificate 82 | //cert: undefined, 83 | // Path to the certificate private key 84 | //key: undefined, 85 | }, 86 | }, 87 | files: { 88 | // Allows to disable files load 89 | //enabled: true, 90 | // Define folder from where to load collections and routes 91 | //path: "mocks", 92 | // Enable/disable files watcher 93 | //watch: true, 94 | babelRegister: { 95 | // Load @babel/register 96 | //enabled: false, 97 | // Options for @babel/register 98 | //options: {}, 99 | }, 100 | }, 101 | variantHandlers: { 102 | // Variant Handlers to be registered 103 | //register: [], 104 | }, 105 | }; 106 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cat-protocol/cat721-cli", 3 | "version": "0.1.0", 4 | "description": "", 5 | "author": "catprotocol.org", 6 | "license": "MIT", 7 | "type": "commonjs", 8 | "bin": { 9 | "cat-cli": "bin/cat721.js" 10 | }, 11 | "scripts": { 12 | "build": "nest build", 13 | "prepublishOnly": "yarn build", 14 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 15 | "mocks": "mocks-server", 16 | "cli:dev": "nest start --", 17 | "cli": "node dist/main", 18 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 19 | "test": "jest --passWithNoTests", 20 | "test:watch": "jest --watch", 21 | "test:cov": "jest --coverage", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "postinstall": "node scripts/postinstall.js" 24 | }, 25 | "dependencies": { 26 | "@bitcoinerlab/descriptors": "^2.2.0", 27 | "@cat-protocol/cat-smartcontracts": "^0.2.5", 28 | "@cmdcode/tapscript": "^1.4.6", 29 | "@nestjs/common": "^10.0.0", 30 | "@nestjs/core": "^10.0.0", 31 | "@nestjs/platform-express": "^10.0.0", 32 | "@types/inquirer": "^8.1.3", 33 | "bigi": "^1.4.2", 34 | "bip32": "^4.0.0", 35 | "bip39": "^3.1.0", 36 | "cbor": "^9.0.2", 37 | "decimal.js": "^10.4.3", 38 | "dotenv": "^16.4.5", 39 | "https-proxy-agent": "^7.0.5", 40 | "nest-commander": "^3.14.0", 41 | "node-fetch-cjs": "^3.3.2", 42 | "reflect-metadata": "^0.2.0", 43 | "rxjs": "^7.8.1", 44 | "tiny-secp256k1": "^2.2.3" 45 | }, 46 | "devDependencies": { 47 | "@mocks-server/main": "^4.1.0", 48 | "@nestjs/cli": "^10.0.0", 49 | "@nestjs/schematics": "^10.0.0", 50 | "@nestjs/testing": "^10.0.0", 51 | "@types/express": "^4.17.17", 52 | "@types/jest": "^29.5.2", 53 | "@types/node": "^20.3.1", 54 | "@types/supertest": "^6.0.0", 55 | "@typescript-eslint/eslint-plugin": "^7.0.0", 56 | "@typescript-eslint/parser": "^7.0.0", 57 | "eslint": "^8.42.0", 58 | "eslint-config-prettier": "^9.0.0", 59 | "eslint-plugin-prettier": "^5.0.0", 60 | "jest": "^29.5.0", 61 | "prettier": "^3.0.0", 62 | "source-map-support": "^0.5.21", 63 | "supertest": "^7.0.0", 64 | "ts-jest": "^29.1.0", 65 | "ts-loader": "^9.4.3", 66 | "ts-node": "^10.9.1", 67 | "tsconfig-paths": "^4.2.0", 68 | "typescript": "=5.3.3", 69 | "webpack": "~5.92.1" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": "src", 78 | "testRegex": ".*\\.spec\\.ts$", 79 | "transform": { 80 | "^.+\\.(t|j)s$": "ts-jest" 81 | }, 82 | "collectCoverageFrom": [ 83 | "**/*.(t|j)s" 84 | ], 85 | "coverageDirectory": "../coverage", 86 | "testEnvironment": "node" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /resource/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/0.png -------------------------------------------------------------------------------- /resource/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/1.png -------------------------------------------------------------------------------- /resource/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/2.png -------------------------------------------------------------------------------- /resource/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/3.png -------------------------------------------------------------------------------- /resource/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/4.png -------------------------------------------------------------------------------- /resource/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/5.png -------------------------------------------------------------------------------- /resource/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/6.png -------------------------------------------------------------------------------- /resource/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/7.png -------------------------------------------------------------------------------- /resource/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/8.png -------------------------------------------------------------------------------- /resource/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CATProtocol/cat721-cli/3e4b5d6c95255971f22190778a6a2b4e2b1886ee/resource/9.png -------------------------------------------------------------------------------- /scripts/metadata.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { getRawTransaction, btc } from '../dist/common'; 3 | import { ConfigService } from '../dist/providers'; 4 | import { getCatCommitScript } from '@cat-protocol/cat-smartcontracts'; 5 | import { readFileSync } from 'fs'; 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | const cbor = require('cbor'); 8 | 9 | function encodeMetaData( 10 | metadata: any = { 11 | name: 'cat721', 12 | symbol: 'cat721', 13 | description: 'this is a cat721 nft collection', 14 | max: 1000n, 15 | }, 16 | ) { 17 | const icon = readFileSync(join(__dirname, '..', 'logo.png')).toString( 18 | 'base64', 19 | ); 20 | 21 | Object.assign(metadata, { 22 | minterMd5: '4b17fb22a536da0550d51a1cd9003911', 23 | }); 24 | 25 | Object.assign(metadata, { 26 | icon: icon, 27 | }); 28 | 29 | // Object.assign(metadata, { 30 | // minterMd5: '4b17fb22a536da0550d51a1cd9003911', 31 | // }); 32 | 33 | const commitScript = getCatCommitScript( 34 | '95dd2038a862d8f9327939bc43d182185e4e678a34136276ca52bf4b5355fa3c', 35 | metadata, 36 | false, 37 | ); 38 | 39 | decodeLockingScript(btc.Script.fromHex(commitScript)); 40 | } 41 | 42 | //encodeMetaData(); 43 | 44 | function decodeLockingScript(lockingScript: btc.Script) { 45 | let metadataHex = ''; 46 | for (let i = 0; i < lockingScript.chunks.length; i++) { 47 | const chunk = lockingScript.chunks[i]; 48 | 49 | if (chunk.opcodenum === 3 && chunk.buf.toString('hex') === '636174') { 50 | for (let j = i + 2; i < lockingScript.chunks.length; j++) { 51 | const metadatachunk = lockingScript.chunks[j]; 52 | if ( 53 | metadatachunk.opcodenum === btc.Opcode.OP_PUSHDATA2 || 54 | metadatachunk.opcodenum === btc.Opcode.OP_PUSHDATA1 55 | ) { 56 | metadataHex += metadatachunk.buf.toString('hex'); 57 | } else if (metadatachunk.opcodenum === btc.Opcode.OP_ENDIF) { 58 | break; 59 | } 60 | } 61 | } 62 | } 63 | 64 | try { 65 | console.log(cbor.decodeAllSync(metadataHex)); // [2, 2] 66 | } catch (e) { 67 | // Throws on invalid input 68 | console.log('e', e); 69 | } 70 | } 71 | async function decodeMetaData(txid: string) { 72 | const config = new ConfigService(); 73 | config.loadCliConfig(join(__dirname, '..', 'config.json')); 74 | const txHex = await getRawTransaction(config, txid); 75 | 76 | if (txHex instanceof Error) { 77 | throw txHex; 78 | } 79 | 80 | const tx = new btc.Transaction(txHex); 81 | 82 | const witnesses = tx.inputs[0].getWitnesses(); 83 | 84 | const lockingScript = btc.Script.fromBuffer(witnesses[witnesses.length - 2]); 85 | 86 | decodeLockingScript(lockingScript); 87 | } 88 | 89 | decodeMetaData( 90 | '9eac41082f8d045d2d31d0fa635fef44fba9df64e10da7a502f1b1def1843916', 91 | ); 92 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const sourceFile = path.join(__dirname, '..', 'config.example.json'); 5 | const destinationFile = path.join(__dirname, '..', 'config.json'); 6 | 7 | // check if the file exists at the destination 8 | if (!fs.existsSync(destinationFile)) { 9 | // copy the file if it doesn't exist 10 | fs.copyFileSync(sourceFile, destinationFile); 11 | console.log(`copied .env from ${sourceFile}`); 12 | } else { 13 | console.log(`.env already exists ${destinationFile}`); 14 | } 15 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DeployCommand } from './commands/deploy/deploy.command'; 3 | import { MintCommand } from './commands/mint/mint.command'; 4 | import { SendCommand } from './commands/send/send.command'; 5 | import { WalletCommand } from './commands/wallet/wallet.command'; 6 | import { ConfigService, SpendService, WalletService } from './providers'; 7 | import { VersionCommand } from './commands/version.command'; 8 | 9 | @Module({ 10 | imports: [], 11 | controllers: [], 12 | providers: [ 13 | WalletService, 14 | ConfigService, 15 | SpendService, 16 | VersionCommand, 17 | DeployCommand, 18 | MintCommand, 19 | SendCommand, 20 | ...WalletCommand.registerWithSubCommands(), 21 | ], 22 | }) 23 | export class AppModule {} 24 | -------------------------------------------------------------------------------- /src/collection/collection.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { 4 | CollectionInfo, 5 | CollectionMetadata, 6 | getCollectionInfo, 7 | logerror, 8 | } from 'src/common'; 9 | import { ConfigService } from 'src/providers'; 10 | 11 | export function getAllCollectionInfos(config: ConfigService): CollectionInfo[] { 12 | const path = getCollectionInfoPath(config); 13 | 14 | try { 15 | if (existsSync(path)) { 16 | const bigintKeys = ['max', 'premine']; 17 | const collectionInfos = JSON.parse( 18 | readFileSync(path).toString(), 19 | (key, value) => { 20 | if (bigintKeys.includes(key)) { 21 | return BigInt(value); 22 | } 23 | return value; 24 | }, 25 | ) as Array; 26 | return collectionInfos; 27 | } else { 28 | return []; 29 | } 30 | } catch (error) { 31 | logerror('getAllCollectionInfos failed!', error); 32 | } 33 | 34 | return []; 35 | } 36 | 37 | export async function findCollectionInfoById( 38 | config: ConfigService, 39 | id: string, 40 | ): Promise { 41 | const collectionInfos = getAllCollectionInfos(config); 42 | let collectionInfo = collectionInfos.find( 43 | (collection) => collection.collectionId === id, 44 | ); 45 | if (collectionInfo) { 46 | return collectionInfo; 47 | } 48 | 49 | collectionInfo = await getCollectionInfo(config, id); 50 | 51 | if (collectionInfo) { 52 | saveCollectionInfo(collectionInfo, config); 53 | } 54 | 55 | return collectionInfo; 56 | } 57 | 58 | function saveCollectionInfo( 59 | collectionInfo: CollectionInfo, 60 | config: ConfigService, 61 | ): CollectionInfo[] { 62 | const collectionInfos = getAllCollectionInfos(config); 63 | collectionInfos.push(collectionInfo); 64 | const path = getCollectionInfoPath(config); 65 | try { 66 | writeFileSync(path, JSON.stringify(collectionInfos, null, 1)); 67 | } catch (error) { 68 | console.error('save token metadata error:', error); 69 | } 70 | 71 | return collectionInfos; 72 | } 73 | 74 | export function addCollectionInfo( 75 | config: ConfigService, 76 | tokenId: string, 77 | collectionMetadata: CollectionMetadata, 78 | tokenAddr: string, 79 | minterAddr: string, 80 | genesisTxid: string, 81 | revealTxid: string, 82 | ) { 83 | const collectionInfo: CollectionInfo = { 84 | metadata: collectionMetadata, 85 | collectionId: tokenId, 86 | collectionAddr: tokenAddr, 87 | minterAddr: minterAddr, 88 | genesisTxid, 89 | revealTxid, 90 | timestamp: new Date().getTime(), 91 | }; 92 | saveCollectionInfo(collectionInfo, config); 93 | return collectionInfo; 94 | } 95 | 96 | export function getCollectionInfoPath(config: ConfigService) { 97 | return join(config.getDataDir(), 'collections.json'); 98 | } 99 | -------------------------------------------------------------------------------- /src/collection/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collection'; 2 | -------------------------------------------------------------------------------- /src/commands/base.command.ts: -------------------------------------------------------------------------------- 1 | import { accessSync, constants } from 'fs'; 2 | import { Option, CommandRunner } from 'nest-commander'; 3 | import { CliConfig, logerror, resolveConfigPath } from 'src/common'; 4 | import { WalletService } from 'src/providers'; 5 | import { ConfigService } from 'src/providers/configService'; 6 | import { URL } from 'url'; 7 | export interface BaseCommandOptions { 8 | config?: string; 9 | network?: string; 10 | tracker?: string; 11 | dataDir?: string; 12 | rpcurl?: string; 13 | rpcusername?: string; 14 | rpcpassword?: string; 15 | } 16 | 17 | export abstract class BaseCommand extends CommandRunner { 18 | constructor( 19 | protected readonly walletService: WalletService, 20 | protected readonly configService: ConfigService, 21 | protected readonly autoLoadWallet: boolean = true, 22 | ) { 23 | super(); 24 | } 25 | 26 | abstract cat_cli_run( 27 | passedParams: string[], 28 | options?: Record, 29 | ): Promise; 30 | 31 | async run( 32 | passedParams: string[], 33 | options?: BaseCommandOptions, 34 | ): Promise { 35 | const configPath = resolveConfigPath(options?.config || ''); 36 | 37 | const error = this.configService.loadCliConfig(configPath); 38 | 39 | if (error instanceof Error) { 40 | console.warn('WARNING:', error.message); 41 | } 42 | 43 | const cliConfig = this.takeConfig(options); 44 | 45 | this.configService.mergeCliConfig(cliConfig); 46 | 47 | if (this.autoLoadWallet) { 48 | const wallet = this.walletService.loadWallet(); 49 | 50 | if (wallet === null) { 51 | return; 52 | } 53 | } 54 | 55 | return this.cat_cli_run(passedParams, options); 56 | } 57 | 58 | protected takeConfig(options: BaseCommandOptions): CliConfig { 59 | /** 60 | * { 61 | * network: 'fractal-mainnet', 62 | * trackerApiHost: 'http://127.0.0.1:3000', 63 | * dataDir: '.', 64 | * apiHost: { 65 | * url: 'http://127.0.0.1:8332', 66 | * username: '', 67 | * password: '', 68 | * } 69 | * } 70 | */ 71 | const cliConfig = {}; 72 | 73 | if (options.network) { 74 | Object.assign(cliConfig, { 75 | network: options.network, 76 | }); 77 | } 78 | 79 | if (options.dataDir) { 80 | Object.assign(cliConfig, { 81 | dataDir: options.dataDir, 82 | }); 83 | } 84 | 85 | if (options.tracker) { 86 | Object.assign(cliConfig, { 87 | tracker: options.tracker, 88 | }); 89 | } 90 | 91 | const rpc = null; 92 | 93 | if (options.rpcurl) { 94 | Object.assign(rpc, { 95 | url: options.rpcurl, 96 | }); 97 | } 98 | 99 | if (options.rpcusername) { 100 | Object.assign(rpc, { 101 | username: options.rpcusername, 102 | }); 103 | } 104 | 105 | if (options.rpcpassword) { 106 | Object.assign(rpc, { 107 | password: options.rpcpassword, 108 | }); 109 | } 110 | 111 | if (rpc !== null) { 112 | Object.assign(cliConfig, { 113 | rpc: rpc, 114 | }); 115 | } 116 | 117 | return cliConfig as CliConfig; 118 | } 119 | 120 | @Option({ 121 | flags: '-c, --config [config file]', 122 | description: 'Special a config file', 123 | }) 124 | parseConfig(val: string): string { 125 | const configPath = resolveConfigPath(val); 126 | try { 127 | accessSync(configPath, constants.R_OK | constants.W_OK); 128 | return configPath; 129 | } catch (error) { 130 | logerror(`can\'t access config file: ${configPath}`, error); 131 | process.exit(-1); 132 | } 133 | } 134 | 135 | @Option({ 136 | flags: '-d, --datadir [datadir]', 137 | description: 'Special a data dir', 138 | }) 139 | parseDataDir(val: string): string { 140 | return val; 141 | } 142 | 143 | @Option({ 144 | flags: '-n, --network [network]', 145 | description: 'Special a network', 146 | choices: ['fractal-mainnet', 'fractal-testnet', 'btc-signet'], 147 | }) 148 | parseNetwork(val: string): string { 149 | if ( 150 | val === 'fractal-mainnet' || 151 | val === 'fractal-testnet' || 152 | val === 'btc-signet' 153 | ) { 154 | return val; 155 | } 156 | throw new Error(`Invalid network: \'${val}\'\n`); 157 | } 158 | 159 | @Option({ 160 | flags: '-t, --tracker [tracker]', 161 | description: 'Special a tracker URL', 162 | }) 163 | parseTracker(val: string): string { 164 | try { 165 | new URL(val); 166 | } catch (error) { 167 | throw new Error(`Invalid tracker URL:${val}\n`); 168 | } 169 | return val; 170 | } 171 | 172 | @Option({ 173 | flags: '--rpc-url [rpcurl]', 174 | description: 'Special a rpc URL', 175 | }) 176 | parseRpcUrl(val: string): string { 177 | try { 178 | new URL(val); 179 | } catch (error) { 180 | throw new Error(`Invalid rpc URL:${val}\n`); 181 | } 182 | return val; 183 | } 184 | 185 | @Option({ 186 | flags: '--rpc-username [rpcusername]', 187 | description: 'Special a rpc username', 188 | }) 189 | parseRpcUsername(val: string): string { 190 | return val; 191 | } 192 | 193 | @Option({ 194 | flags: '--rpc-password [rpcpassword]', 195 | description: 'Special a rpc password', 196 | }) 197 | parseRpcPassword(val: string): string { 198 | return val; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/commands/boardcast.command.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'nest-commander'; 2 | import { BaseCommand, BaseCommandOptions } from './base.command'; 3 | import { ConfigService, SpendService, WalletService } from 'src/providers'; 4 | import { CliConfig, getFeeRate } from 'src/common'; 5 | 6 | export interface BoardcastCommandOptions extends BaseCommandOptions { 7 | maxFeeRate?: number; 8 | feeRate?: number; 9 | } 10 | 11 | export abstract class BoardcastCommand extends BaseCommand { 12 | constructor( 13 | protected readonly spendSerivce: SpendService, 14 | protected readonly walletService: WalletService, 15 | protected readonly configService: ConfigService, 16 | ) { 17 | super(walletService, configService); 18 | } 19 | 20 | override async run( 21 | passedParams: string[], 22 | options?: BaseCommandOptions, 23 | ): Promise { 24 | await super.run(passedParams, options); 25 | this.spendSerivce.save(); 26 | } 27 | 28 | protected takeConfig(options: BoardcastCommandOptions): CliConfig { 29 | const config = super.takeConfig(options); 30 | if (options.maxFeeRate) { 31 | Object.assign(config, { 32 | maxFeeRate: options.maxFeeRate, 33 | }); 34 | } 35 | 36 | if (options.feeRate) { 37 | Object.assign(config, { 38 | feeRate: options.feeRate, 39 | }); 40 | } 41 | return config; 42 | } 43 | 44 | @Option({ 45 | flags: '--max-fee-rate [maxFeeRate]', 46 | description: 'max fee rate', 47 | }) 48 | parseMaxFeeRate(val: string): number { 49 | try { 50 | return parseInt(val); 51 | } catch (error) {} 52 | return undefined; 53 | } 54 | 55 | @Option({ 56 | flags: '--fee-rate [feeRate]', 57 | description: 'fee rate', 58 | }) 59 | parseFeeRate(val: string): number { 60 | try { 61 | return parseInt(val); 62 | } catch (error) {} 63 | return undefined; 64 | } 65 | 66 | async getFeeRate(): Promise { 67 | const feeRate = this.configService.getFeeRate(); 68 | 69 | if (feeRate > 0) { 70 | return feeRate; 71 | } 72 | 73 | const networkFeeRate = await getFeeRate( 74 | this.configService, 75 | this.walletService, 76 | ); 77 | 78 | const maxFeeRate = this.configService.getMaxFeeRate(); 79 | 80 | if (maxFeeRate > 0) { 81 | return Math.min(maxFeeRate, networkFeeRate); 82 | } 83 | 84 | return networkFeeRate; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/deploy/deploy.command.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from 'nest-commander'; 2 | import { 3 | getUtxos, 4 | logerror, 5 | btc, 6 | CollectionMetadata, 7 | checkOpenMintMetadata, 8 | checkClosedMintMetadata, 9 | } from 'src/common'; 10 | import { 11 | generateCollectionMerkleTree, 12 | deploy as openMintDeploy, 13 | } from './nft.open-mint'; 14 | 15 | import { deploy as closedMintDeploy } from './nft.closed-mint'; 16 | import { deploy as closedParallelMintDeploy } from './nft.parallel-closed-mint'; 17 | import { ConfigService } from 'src/providers/configService'; 18 | import { SpendService, WalletService } from 'src/providers'; 19 | import { Inject } from '@nestjs/common'; 20 | import { addCollectionInfo } from 'src/collection'; 21 | import { isAbsolute, join } from 'path'; 22 | import { accessSync, constants, lstatSync, readFileSync } from 'fs'; 23 | import { 24 | BoardcastCommand, 25 | BoardcastCommandOptions, 26 | } from '../boardcast.command'; 27 | import { 28 | NftClosedMinter, 29 | NftOpenMinter, 30 | NftParallelClosedMinter, 31 | } from '@cat-protocol/cat-smartcontracts'; 32 | 33 | interface DeployCommandOptions extends BoardcastCommandOptions { 34 | config?: string; 35 | name?: string; 36 | symbol?: string; 37 | description?: string; 38 | icon?: string; 39 | max?: bigint; 40 | premine?: bigint; 41 | metadata?: string; 42 | resource?: string; 43 | type?: string; 44 | 45 | openMint?: boolean; 46 | 47 | parallel?: boolean; 48 | } 49 | 50 | function isEmptyOption(options: DeployCommandOptions) { 51 | const { config, name, symbol, description, max, icon, metadata } = options; 52 | return ( 53 | config === undefined && 54 | name === undefined && 55 | symbol === undefined && 56 | description === undefined && 57 | max === undefined && 58 | icon === undefined && 59 | metadata === undefined 60 | ); 61 | } 62 | 63 | @Command({ 64 | name: 'deploy', 65 | description: 'Deploy an open-mint non-fungible token (NFT)', 66 | }) 67 | export class DeployCommand extends BoardcastCommand { 68 | constructor( 69 | @Inject() private readonly spendService: SpendService, 70 | @Inject() protected readonly walletService: WalletService, 71 | @Inject() protected readonly configService: ConfigService, 72 | ) { 73 | super(spendService, walletService, configService); 74 | } 75 | 76 | async cat_cli_run( 77 | passedParams: string[], 78 | options?: DeployCommandOptions, 79 | ): Promise { 80 | try { 81 | const address = this.walletService.getAddress(); 82 | 83 | let metadata: CollectionMetadata; 84 | if (options.metadata) { 85 | const content = readFileSync(options.metadata).toString(); 86 | metadata = JSON.parse(content); 87 | } else { 88 | const { name, symbol, description, max, premine } = options; 89 | 90 | metadata = { 91 | name, 92 | symbol, 93 | description, 94 | max, 95 | premine, 96 | } as CollectionMetadata; 97 | } 98 | 99 | if (isEmptyOption(options)) { 100 | logerror( 101 | 'Should deploy with `--metadata=your.json` or with options like `--name=cat721 --symbol=cat721 --description="this is cat721 nft" --max=2100` ', 102 | new Error('No metadata found'), 103 | ); 104 | return; 105 | } 106 | 107 | const err = options.openMint 108 | ? checkOpenMintMetadata(metadata) 109 | : checkClosedMintMetadata(metadata); 110 | 111 | if (err instanceof Error) { 112 | logerror('Invalid token metadata!', err); 113 | return; 114 | } 115 | 116 | const feeRate = await this.getFeeRate(); 117 | 118 | const utxos = await getUtxos( 119 | this.configService, 120 | this.walletService, 121 | address, 122 | ); 123 | 124 | if (utxos.length === 0) { 125 | console.warn('Insufficient satoshi balance!'); 126 | return; 127 | } 128 | 129 | if (options.openMint) { 130 | Object.assign(metadata, { 131 | minterMd5: NftOpenMinter.getArtifact().md5, 132 | }); 133 | } else if (options.parallel) { 134 | Object.assign(metadata, { 135 | minterMd5: NftParallelClosedMinter.getArtifact().md5, 136 | }); 137 | } else { 138 | Object.assign(metadata, { 139 | minterMd5: NftClosedMinter.getArtifact().md5, 140 | }); 141 | } 142 | 143 | let result: { 144 | genesisTx: btc.Transaction; 145 | revealTx: btc.Transaction; 146 | tokenId: string; 147 | tokenAddr: string; 148 | minterAddr: string; 149 | } | null = null; 150 | 151 | const contentType = options.type || 'image/png'; 152 | 153 | const icon = options.icon 154 | ? { 155 | type: contentType, 156 | body: options.icon, 157 | } 158 | : undefined; 159 | 160 | if (options.openMint) { 161 | const pubkeyX = this.walletService.getXOnlyPublicKey(); 162 | 163 | const resourceDir = options.resource 164 | ? options.resource 165 | : join(process.cwd(), 'resource'); 166 | 167 | const collectionMerkleTree = generateCollectionMerkleTree( 168 | metadata.max, 169 | pubkeyX, 170 | contentType, 171 | resourceDir, 172 | ); 173 | 174 | result = await openMintDeploy( 175 | metadata, 176 | feeRate, 177 | utxos, 178 | this.walletService, 179 | this.configService, 180 | collectionMerkleTree.merkleRoot, 181 | icon, 182 | ); 183 | } else if (options.parallel) { 184 | result = await closedParallelMintDeploy( 185 | metadata, 186 | feeRate, 187 | utxos, 188 | this.walletService, 189 | this.configService, 190 | icon, 191 | ); 192 | } else { 193 | result = await closedMintDeploy( 194 | metadata, 195 | feeRate, 196 | utxos, 197 | this.walletService, 198 | this.configService, 199 | icon, 200 | ); 201 | } 202 | 203 | if (!result) { 204 | console.log(`deploying Token ${metadata.name} failed!`); 205 | return; 206 | } 207 | 208 | this.spendService.updateTxsSpends([result.genesisTx, result.revealTx]); 209 | 210 | console.log(`Nft collection ${metadata.symbol} has been deployed.`); 211 | console.log(`CollectionId: ${result.tokenId}`); 212 | console.log(`Genesis txid: ${result.genesisTx.id}`); 213 | console.log(`Reveal txid: ${result.revealTx.id}`); 214 | 215 | addCollectionInfo( 216 | this.configService, 217 | result.tokenId, 218 | metadata, 219 | result.tokenAddr, 220 | result.minterAddr, 221 | result.genesisTx.id, 222 | result.revealTx.id, 223 | ); 224 | } catch (error) { 225 | logerror('Deploy failed!', error); 226 | } 227 | } 228 | 229 | @Option({ 230 | flags: '-n, --name [name]', 231 | name: 'name', 232 | description: 'token name', 233 | }) 234 | parseName(val: string): string { 235 | if (!val) { 236 | logerror("Name can't be empty!", new Error('Empty symbol')); 237 | process.exit(0); 238 | } 239 | return val; 240 | } 241 | 242 | @Option({ 243 | flags: '-s, --symbol [symbol]', 244 | name: 'symbol', 245 | description: 'token symbol', 246 | }) 247 | parseSymbol(val: string): string { 248 | if (!val) { 249 | logerror("Symbol can't be empty!", new Error('Empty symbol')); 250 | process.exit(0); 251 | } 252 | 253 | return val; 254 | } 255 | 256 | @Option({ 257 | flags: '-d, --description [description]', 258 | name: 'description', 259 | description: 'description', 260 | }) 261 | parseDescription(val: string): string { 262 | if (!val) { 263 | logerror("Description can't be empty!", new Error('Empty description')); 264 | process.exit(0); 265 | } 266 | 267 | return val; 268 | } 269 | 270 | @Option({ 271 | flags: '-m, --max [max]', 272 | name: 'max', 273 | description: 'token max supply', 274 | }) 275 | parseMax(val: string): bigint { 276 | if (!val) { 277 | logerror('Invalid token max supply!', new Error('Empty max supply')); 278 | process.exit(0); 279 | } 280 | try { 281 | return BigInt(val); 282 | } catch (error) { 283 | logerror('Invalid token max supply!', error); 284 | process.exit(0); 285 | } 286 | } 287 | 288 | @Option({ 289 | flags: '-m, --metadata [metadata]', 290 | name: 'metadata', 291 | description: 'token metadata', 292 | }) 293 | parseMetadata(val: string): string { 294 | if (!val) { 295 | logerror("metadata can't be empty!", new Error()); 296 | process.exit(0); 297 | } 298 | 299 | const metadata = isAbsolute(val) ? val : join(process.cwd(), val); 300 | 301 | try { 302 | accessSync(metadata, constants.R_OK); 303 | return metadata; 304 | } catch (error) { 305 | logerror(`can\'t access metadata file: ${metadata}`, error); 306 | process.exit(0); 307 | } 308 | } 309 | 310 | @Option({ 311 | flags: '-i, --icon [icon]', 312 | name: 'icon', 313 | description: 'token icon', 314 | }) 315 | parseIcon(val: string): string { 316 | if (!val) { 317 | logerror("icon can't be empty!", new Error()); 318 | process.exit(0); 319 | } 320 | 321 | const iconFile = isAbsolute(val) ? val : join(process.cwd(), val); 322 | 323 | try { 324 | accessSync(iconFile, constants.R_OK); 325 | return readFileSync(iconFile).toString('hex'); 326 | } catch (error) { 327 | logerror(`can\'t access iconFile file: ${iconFile}`, error); 328 | process.exit(0); 329 | } 330 | } 331 | 332 | @Option({ 333 | flags: '-r, --resource [resource]', 334 | description: 'resource of the minted nft', 335 | }) 336 | parseResource(val: string): string { 337 | if (!val) { 338 | logerror("resource can't be empty!", new Error()); 339 | process.exit(0); 340 | } 341 | 342 | const resource = isAbsolute(val) ? val : join(process.cwd(), val); 343 | 344 | try { 345 | const stat = lstatSync(resource); 346 | 347 | if (stat.isDirectory()) { 348 | return resource; 349 | } else { 350 | throw new Error(`${resource} is not directory`); 351 | } 352 | } catch (error) { 353 | logerror(`can\'t access resource dir: ${resource}`, error); 354 | process.exit(0); 355 | } 356 | } 357 | 358 | @Option({ 359 | flags: '-t, --type [type]', 360 | description: 'content type of the resource', 361 | }) 362 | parseType(val: string): string { 363 | if (!val) { 364 | logerror("resource can't be empty!", new Error()); 365 | process.exit(0); 366 | } 367 | 368 | return val; 369 | } 370 | 371 | @Option({ 372 | flags: '-p, --premine [premine]', 373 | name: 'premine', 374 | description: 'premine nft', 375 | }) 376 | parsePremine(val: string): bigint { 377 | if (!val) { 378 | return BigInt(0); 379 | } 380 | try { 381 | return BigInt(val); 382 | } catch (error) { 383 | logerror('Invalid token premine!', error); 384 | process.exit(0); 385 | } 386 | } 387 | 388 | @Option({ 389 | flags: '--openMint [openMint]', 390 | name: 'openMint', 391 | description: 'openMint nft', 392 | }) 393 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 394 | parseOpenMint(_val: string): boolean { 395 | return true; 396 | } 397 | 398 | @Option({ 399 | flags: '--parallel [parallel]', 400 | name: 'parallel', 401 | description: 'parallel closed mint', 402 | }) 403 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 404 | parseParallel(_val: string): boolean { 405 | return true; 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/commands/deploy/nft.closed-mint.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import { 3 | broadcast, 4 | script2P2TR, 5 | toStateScript, 6 | p2tr2Address, 7 | outpoint2ByteString, 8 | Postage, 9 | btc, 10 | logerror, 11 | getNftClosedMinterContractP2TR, 12 | toTokenAddress, 13 | CollectionMetadata, 14 | getNFTContractP2TR, 15 | } from 'src/common'; 16 | 17 | import { 18 | ProtocolState, 19 | getSHPreimage, 20 | NftClosedMinterProto, 21 | NftClosedMinterState, 22 | int32, 23 | getCatCollectionCommitScript, 24 | } from '@cat-protocol/cat-smartcontracts'; 25 | import { ConfigService, WalletService } from 'src/providers'; 26 | 27 | function getMinter(wallet: WalletService, genesisId: string, max: int32) { 28 | const issuerAddress = wallet.getAddress(); 29 | return getNftClosedMinterContractP2TR( 30 | toTokenAddress(issuerAddress), 31 | genesisId, 32 | max, 33 | ); 34 | } 35 | 36 | export function getMinterInitialTxState( 37 | nftP2TR: string, 38 | collectionMax: bigint, 39 | ): { 40 | protocolState: ProtocolState; 41 | states: NftClosedMinterState[]; 42 | } { 43 | const protocolState = ProtocolState.getEmptyState(); 44 | 45 | let quotaStep = collectionMax / 5n; 46 | 47 | let mod = collectionMax % 5n; 48 | 49 | const states: NftClosedMinterState[] = []; 50 | 51 | let maxMinterOutput = 5; 52 | maxMinterOutput = Math.min(Number(collectionMax), 5); 53 | 54 | // if collectionMax < 5n 55 | if (collectionMax < 5n) { 56 | quotaStep = 1n; 57 | mod = 0n; 58 | } 59 | 60 | for (let i = 0; i < maxMinterOutput; i++) { 61 | const start = quotaStep * BigInt(i); 62 | let quotaMaxLocalId = start + quotaStep; 63 | if (i == maxMinterOutput - 1) { 64 | quotaMaxLocalId += mod; 65 | } 66 | 67 | const state = NftClosedMinterProto.create(nftP2TR, quotaMaxLocalId, start); 68 | 69 | states.push(state); 70 | 71 | const outputState = NftClosedMinterProto.toByteString(state); 72 | protocolState.updateDataList(i, outputState); 73 | } 74 | 75 | return { 76 | protocolState, 77 | states, 78 | }; 79 | } 80 | 81 | const buildRevealTx = ( 82 | wallet: WalletService, 83 | genesisId: string, 84 | lockingScript: Buffer, 85 | metadata: CollectionMetadata, 86 | commitTx: btc.Transaction, 87 | feeRate: number, 88 | ): btc.Transaction => { 89 | const { p2tr: minterP2TR } = getMinter( 90 | wallet, 91 | outpoint2ByteString(genesisId), 92 | metadata.max, 93 | ); 94 | 95 | const { tapScript, cblock } = script2P2TR(lockingScript); 96 | const { p2tr: nftP2TR } = getNFTContractP2TR(minterP2TR); 97 | 98 | const { protocolState: txState, states } = getMinterInitialTxState( 99 | nftP2TR, 100 | metadata.max, 101 | ); 102 | 103 | const revealTx = new btc.Transaction() 104 | .from([ 105 | { 106 | txId: commitTx.id, 107 | outputIndex: 0, 108 | script: commitTx.outputs[0].script, 109 | satoshis: commitTx.outputs[0].satoshis, 110 | }, 111 | { 112 | txId: commitTx.id, 113 | outputIndex: 1, 114 | script: commitTx.outputs[1].script, 115 | satoshis: commitTx.outputs[1].satoshis, 116 | }, 117 | ]) 118 | .addOutput( 119 | new btc.Transaction.Output({ 120 | satoshis: 0, 121 | script: toStateScript(txState), 122 | }), 123 | ); 124 | 125 | for (let i = 0; i < states.length; i++) { 126 | revealTx.addOutput( 127 | new btc.Transaction.Output({ 128 | satoshis: Postage.MINTER_POSTAGE, 129 | script: minterP2TR, 130 | }), 131 | ); 132 | } 133 | 134 | revealTx.feePerByte(feeRate); 135 | 136 | const witnesses: Buffer[] = []; 137 | 138 | const { sighash } = getSHPreimage(revealTx, 0, Buffer.from(tapScript, 'hex')); 139 | 140 | const sig = btc.crypto.Schnorr.sign( 141 | wallet.getTaprootPrivateKey(), 142 | sighash.hash, 143 | ); 144 | 145 | for (let i = 0; i < txState.stateHashList.length; i++) { 146 | const txoStateHash = txState.stateHashList[i]; 147 | witnesses.push(Buffer.from(txoStateHash, 'hex')); 148 | } 149 | witnesses.push(sig); 150 | witnesses.push(lockingScript); 151 | witnesses.push(Buffer.from(cblock, 'hex')); 152 | 153 | const interpreter = new btc.Script.Interpreter(); 154 | const flags = 155 | btc.Script.Interpreter.SCRIPT_VERIFY_WITNESS | 156 | btc.Script.Interpreter.SCRIPT_VERIFY_TAPROOT; 157 | 158 | const res = interpreter.verify( 159 | new btc.Script(''), 160 | commitTx.outputs[0].script, 161 | revealTx, 162 | 0, 163 | flags, 164 | witnesses, 165 | commitTx.outputs[0].satoshis, 166 | ); 167 | 168 | if (!res) { 169 | console.error('reveal faild!', interpreter.errstr); 170 | return; 171 | } 172 | 173 | revealTx.inputs[0].witnesses = witnesses; 174 | 175 | wallet.signTx(revealTx); 176 | return revealTx; 177 | }; 178 | 179 | export async function deploy( 180 | metadata: CollectionMetadata, 181 | feeRate: number, 182 | utxos: UTXO[], 183 | wallet: WalletService, 184 | config: ConfigService, 185 | icon?: { 186 | type: string; 187 | body: string; 188 | }, 189 | ): Promise< 190 | | { 191 | revealTx: btc.Transaction; 192 | genesisTx: btc.Transaction; 193 | tokenId: string; 194 | tokenAddr: string; 195 | minterAddr: string; 196 | } 197 | | undefined 198 | > { 199 | const changeAddress: btc.Address = wallet.getAddress(); 200 | 201 | const pubkeyX = wallet.getXOnlyPublicKey(); 202 | const commitScript = getCatCollectionCommitScript(pubkeyX, metadata, icon); 203 | 204 | const lockingScript = Buffer.from(commitScript, 'hex'); 205 | const { p2tr: p2tr } = script2P2TR(lockingScript); 206 | 207 | const changeScript = btc.Script.fromAddress(changeAddress); 208 | 209 | const commitTx = new btc.Transaction() 210 | .from(utxos) 211 | .addOutput( 212 | new btc.Transaction.Output({ 213 | satoshis: Postage.METADATA_POSTAGE, 214 | script: p2tr, 215 | }), 216 | ) 217 | .addOutput( 218 | /** utxo to pay revealTx fee */ 219 | new btc.Transaction.Output({ 220 | satoshis: 0, 221 | script: changeScript, 222 | }), 223 | ) 224 | .feePerByte(feeRate) 225 | .change(changeAddress); 226 | 227 | if (commitTx.getChangeOutput() === null) { 228 | throw new Error('Insufficient satoshi balance!'); 229 | } 230 | 231 | const dummyGenesisId = `${'0000000000000000000000000000000000000000000000000000000000000000'}_0`; 232 | 233 | const revealTxDummy = buildRevealTx( 234 | wallet, 235 | dummyGenesisId, 236 | lockingScript, 237 | metadata, 238 | commitTx, 239 | feeRate, 240 | ); 241 | 242 | const revealTxFee = 243 | revealTxDummy.vsize * feeRate + 244 | Postage.MINTER_POSTAGE * (revealTxDummy.outputs.length - 1) - 245 | Postage.METADATA_POSTAGE; 246 | 247 | commitTx.outputs[1].satoshis = Math.max(revealTxFee, 546); 248 | 249 | commitTx.change(changeAddress); 250 | 251 | if (commitTx.getChangeOutput() !== null) { 252 | commitTx.getChangeOutput().satoshis -= 1; 253 | } 254 | 255 | wallet.signTx(commitTx); 256 | 257 | const genesisId = `${commitTx.id}_0`; 258 | 259 | const revealTx = buildRevealTx( 260 | wallet, 261 | genesisId, 262 | lockingScript, 263 | metadata, 264 | commitTx, 265 | feeRate, 266 | ); 267 | 268 | const { p2tr: minterP2TR } = getMinter( 269 | wallet, 270 | outpoint2ByteString(genesisId), 271 | metadata.max, 272 | ); 273 | const { p2tr: tokenP2TR } = getNFTContractP2TR(minterP2TR); 274 | 275 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 276 | const commitTxId = await broadcast( 277 | config, 278 | wallet, 279 | commitTx.uncheckedSerialize(), 280 | ); 281 | 282 | if (commitTxId instanceof Error) { 283 | logerror(`commit failed!`, commitTxId); 284 | return null; 285 | } 286 | 287 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 288 | const revealTxId = await broadcast( 289 | config, 290 | wallet, 291 | revealTx.uncheckedSerialize(), 292 | ); 293 | 294 | if (revealTxId instanceof Error) { 295 | logerror(`reveal failed!`, revealTxId); 296 | return null; 297 | } 298 | 299 | return { 300 | tokenId: genesisId, 301 | tokenAddr: p2tr2Address(tokenP2TR, config.getNetwork()), 302 | minterAddr: p2tr2Address(minterP2TR, config.getNetwork()), 303 | genesisTx: commitTx, 304 | revealTx: revealTx, 305 | }; 306 | } 307 | -------------------------------------------------------------------------------- /src/commands/deploy/nft.open-mint.ts: -------------------------------------------------------------------------------- 1 | import { ByteString, UTXO } from 'scrypt-ts'; 2 | import { 3 | broadcast, 4 | script2P2TR, 5 | toStateScript, 6 | p2tr2Address, 7 | outpoint2ByteString, 8 | Postage, 9 | btc, 10 | logerror, 11 | CollectionMetadata, 12 | getNFTContractP2TR, 13 | getNftOpenMinterContractP2TR, 14 | } from 'src/common'; 15 | 16 | import { 17 | ProtocolState, 18 | getSHPreimage, 19 | int32, 20 | NftOpenMinterProto, 21 | NftOpenMinterState, 22 | NftMerkleLeaf, 23 | getCatNFTCommitScript, 24 | HEIGHT, 25 | NftOpenMinterMerkleTreeData, 26 | getCatCollectionCommitScript, 27 | } from '@cat-protocol/cat-smartcontracts'; 28 | import { ConfigService, WalletService } from 'src/providers'; 29 | import { join } from 'path'; 30 | import { existsSync, readFileSync } from 'fs'; 31 | 32 | function getMinter( 33 | wallet: WalletService, 34 | genesisId: string, 35 | max: int32, 36 | premine: int32, 37 | ) { 38 | const premineAddr = premine > 0n ? wallet.getTokenAddress() : ''; 39 | return getNftOpenMinterContractP2TR(genesisId, max, premine, premineAddr); 40 | } 41 | 42 | function getMinterInitialTxState( 43 | tokenP2TR: string, 44 | merkleRoot: ByteString, 45 | ): { 46 | protocolState: ProtocolState; 47 | data: NftOpenMinterState; 48 | } { 49 | const protocolState = ProtocolState.getEmptyState(); 50 | const minterState = NftOpenMinterProto.create(tokenP2TR, merkleRoot, 0n); 51 | const outputState = NftOpenMinterProto.toByteString(minterState); 52 | protocolState.updateDataList(0, outputState); 53 | return { 54 | protocolState, 55 | data: minterState, 56 | }; 57 | } 58 | 59 | const buildRevealTx = ( 60 | wallet: WalletService, 61 | lockingScript: btc.Script, 62 | commitTx: btc.Transaction, 63 | minterP2TR: string, 64 | merkleRoot: ByteString, 65 | feeRate: number, 66 | ): btc.Transaction => { 67 | const { tapScript, cblock } = script2P2TR(lockingScript); 68 | const { p2tr: tokenP2TR } = getNFTContractP2TR(minterP2TR); 69 | 70 | const { protocolState: txState } = getMinterInitialTxState( 71 | tokenP2TR, 72 | merkleRoot, 73 | ); 74 | 75 | const revealTx = new btc.Transaction() 76 | .from([ 77 | { 78 | txId: commitTx.id, 79 | outputIndex: 0, 80 | script: commitTx.outputs[0].script, 81 | satoshis: commitTx.outputs[0].satoshis, 82 | }, 83 | { 84 | txId: commitTx.id, 85 | outputIndex: 1, 86 | script: commitTx.outputs[1].script, 87 | satoshis: commitTx.outputs[1].satoshis, 88 | }, 89 | ]) 90 | .addOutput( 91 | new btc.Transaction.Output({ 92 | satoshis: 0, 93 | script: toStateScript(txState), 94 | }), 95 | ) 96 | .addOutput( 97 | new btc.Transaction.Output({ 98 | satoshis: Postage.MINTER_POSTAGE, 99 | script: minterP2TR, 100 | }), 101 | ) 102 | .feePerByte(feeRate); 103 | 104 | const witnesses: Buffer[] = []; 105 | 106 | const { sighash } = getSHPreimage(revealTx, 0, Buffer.from(tapScript, 'hex')); 107 | 108 | const sig = btc.crypto.Schnorr.sign( 109 | wallet.getTaprootPrivateKey(), 110 | sighash.hash, 111 | ); 112 | 113 | for (let i = 0; i < txState.stateHashList.length; i++) { 114 | const txoStateHash = txState.stateHashList[i]; 115 | witnesses.push(Buffer.from(txoStateHash, 'hex')); 116 | } 117 | witnesses.push(sig); 118 | witnesses.push(lockingScript); 119 | witnesses.push(Buffer.from(cblock, 'hex')); 120 | 121 | const interpreter = new btc.Script.Interpreter(); 122 | const flags = 123 | btc.Script.Interpreter.SCRIPT_VERIFY_WITNESS | 124 | btc.Script.Interpreter.SCRIPT_VERIFY_TAPROOT; 125 | 126 | const res = interpreter.verify( 127 | new btc.Script(''), 128 | commitTx.outputs[0].script, 129 | revealTx, 130 | 0, 131 | flags, 132 | witnesses, 133 | commitTx.outputs[0].satoshis, 134 | ); 135 | 136 | if (!res) { 137 | console.error('reveal faild!', interpreter.errstr); 138 | return; 139 | } 140 | 141 | revealTx.inputs[0].witnesses = witnesses; 142 | 143 | wallet.signTx(revealTx); 144 | return revealTx; 145 | }; 146 | 147 | export async function deploy( 148 | metadata: CollectionMetadata, 149 | feeRate: number, 150 | utxos: UTXO[], 151 | wallet: WalletService, 152 | config: ConfigService, 153 | merkleRoot: ByteString, 154 | icon?: { 155 | type: string; 156 | body: string; 157 | }, 158 | ): Promise< 159 | | { 160 | revealTx: btc.Transaction; 161 | genesisTx: btc.Transaction; 162 | tokenId: string; 163 | tokenAddr: string; 164 | minterAddr: string; 165 | } 166 | | undefined 167 | > { 168 | const changeAddress: btc.Address = wallet.getAddress(); 169 | 170 | const pubkeyX = wallet.getXOnlyPublicKey(); 171 | const commitScript = getCatCollectionCommitScript(pubkeyX, metadata, icon); 172 | 173 | const lockingScript = Buffer.from(commitScript, 'hex'); 174 | const { p2tr: p2tr } = script2P2TR(lockingScript); 175 | 176 | const changeScript = btc.Script.fromAddress(changeAddress); 177 | 178 | const commitTx = new btc.Transaction() 179 | .from(utxos) 180 | .addOutput( 181 | new btc.Transaction.Output({ 182 | satoshis: Postage.METADATA_POSTAGE, 183 | script: p2tr, 184 | }), 185 | ) 186 | .addOutput( 187 | /** utxo to pay revealTx fee */ 188 | new btc.Transaction.Output({ 189 | satoshis: 0, 190 | script: changeScript, 191 | }), 192 | ) 193 | .feePerByte(feeRate) 194 | .change(changeAddress); 195 | 196 | if (commitTx.getChangeOutput() === null) { 197 | throw new Error('Insufficient satoshi balance!'); 198 | } 199 | 200 | const dummyGenesisId = `${'0000000000000000000000000000000000000000000000000000000000000000'}_0`; 201 | 202 | const { p2tr: dummyMinterP2TR } = getMinter( 203 | wallet, 204 | outpoint2ByteString(dummyGenesisId), 205 | metadata.max, 206 | metadata.premine || 0n, 207 | ); 208 | 209 | const revealTxDummy = buildRevealTx( 210 | wallet, 211 | lockingScript, 212 | commitTx, 213 | dummyMinterP2TR, 214 | merkleRoot, 215 | feeRate, 216 | ); 217 | 218 | const revealTxFee = 219 | revealTxDummy.vsize * feeRate + 220 | Postage.MINTER_POSTAGE * (revealTxDummy.outputs.length - 1) - 221 | Postage.METADATA_POSTAGE; 222 | 223 | commitTx.outputs[1].satoshis = Math.max(revealTxFee, 546); 224 | 225 | commitTx.change(changeAddress); 226 | 227 | if (commitTx.getChangeOutput() === null) { 228 | throw new Error('Insufficient satoshi balance!'); 229 | } 230 | 231 | commitTx.getChangeOutput().satoshis -= 1; 232 | 233 | wallet.signTx(commitTx); 234 | 235 | const genesisId = `${commitTx.id}_0`; 236 | 237 | const { p2tr: minterP2TR } = getMinter( 238 | wallet, 239 | outpoint2ByteString(genesisId), 240 | metadata.max, 241 | metadata.premine || 0n, 242 | ); 243 | 244 | const revealTx = buildRevealTx( 245 | wallet, 246 | lockingScript, 247 | commitTx, 248 | minterP2TR, 249 | merkleRoot, 250 | feeRate, 251 | ); 252 | 253 | const { p2tr: tokenP2TR } = getNFTContractP2TR(minterP2TR); 254 | 255 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 256 | const commitTxId = await broadcast( 257 | config, 258 | wallet, 259 | commitTx.uncheckedSerialize(), 260 | ); 261 | 262 | if (commitTxId instanceof Error) { 263 | logerror(`commit failed!`, commitTxId); 264 | return null; 265 | } 266 | 267 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 268 | const revealTxId = await broadcast( 269 | config, 270 | wallet, 271 | revealTx.uncheckedSerialize(), 272 | ); 273 | 274 | if (revealTxId instanceof Error) { 275 | logerror(`reveal failed!`, revealTxId); 276 | return null; 277 | } 278 | 279 | return { 280 | tokenId: genesisId, 281 | tokenAddr: p2tr2Address(tokenP2TR, config.getNetwork()), 282 | minterAddr: p2tr2Address(minterP2TR, config.getNetwork()), 283 | genesisTx: commitTx, 284 | revealTx: revealTx, 285 | }; 286 | } 287 | 288 | const createMerkleLeaf = function ( 289 | pubkeyX: string, 290 | localId: bigint, 291 | metadata: object, 292 | content: { 293 | type: string; 294 | body: string; 295 | }, 296 | ): NftMerkleLeaf { 297 | const commitScript = getCatNFTCommitScript(pubkeyX, metadata, content); 298 | const lockingScript = Buffer.from(commitScript, 'hex'); 299 | const { p2tr } = script2P2TR(lockingScript); 300 | return { 301 | commitScript: p2tr, 302 | localId: localId, 303 | isMined: false, 304 | }; 305 | }; 306 | 307 | export const generateCollectionMerkleTree = function ( 308 | max: bigint, 309 | pubkeyX: string, 310 | type: string, 311 | resourceDir: string, 312 | ) { 313 | const nftMerkleLeafList: NftMerkleLeaf[] = []; 314 | 315 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 316 | const [_, ext] = type.split('/'); 317 | if (!ext) { 318 | throw new Error(`unknow type: ${type}`); 319 | } 320 | for (let index = 0n; index < max; index++) { 321 | const body = readFileSync(join(resourceDir, `${index}.${ext}`)).toString( 322 | 'hex', 323 | ); 324 | 325 | const metadata = { 326 | localId: index, 327 | }; 328 | 329 | try { 330 | const metadataFile = join(resourceDir, `${index}.json`); 331 | 332 | if (existsSync(metadataFile)) { 333 | const str = readFileSync(metadataFile).toString(); 334 | const obj = JSON.parse(str); 335 | Object.assign(metadata, obj); 336 | } 337 | } catch (error) { 338 | logerror(`readMetaData FAIL, localId: ${index}`, error); 339 | } 340 | 341 | nftMerkleLeafList.push( 342 | createMerkleLeaf(pubkeyX, index, metadata, { 343 | type, 344 | body, 345 | }), 346 | ); 347 | } 348 | 349 | return new NftOpenMinterMerkleTreeData(nftMerkleLeafList, HEIGHT); 350 | }; 351 | -------------------------------------------------------------------------------- /src/commands/deploy/nft.parallel-closed-mint.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import { 3 | broadcast, 4 | script2P2TR, 5 | toStateScript, 6 | p2tr2Address, 7 | outpoint2ByteString, 8 | Postage, 9 | btc, 10 | logerror, 11 | toTokenAddress, 12 | CollectionMetadata, 13 | getNFTContractP2TR, 14 | getNftParallelClosedMinterContractP2TR, 15 | } from 'src/common'; 16 | 17 | import { 18 | ProtocolState, 19 | getSHPreimage, 20 | int32, 21 | getCatCollectionCommitScript, 22 | NftParallelClosedMinterState, 23 | NftParallelClosedMinterProto, 24 | } from '@cat-protocol/cat-smartcontracts'; 25 | import { ConfigService, WalletService } from 'src/providers'; 26 | 27 | function getMinter(wallet: WalletService, genesisId: string, max: int32) { 28 | const issuerAddress = wallet.getAddress(); 29 | return getNftParallelClosedMinterContractP2TR( 30 | toTokenAddress(issuerAddress), 31 | genesisId, 32 | max, 33 | ); 34 | } 35 | 36 | export function getMinterInitialTxState(nftP2TR: string): { 37 | protocolState: ProtocolState; 38 | states: NftParallelClosedMinterState[]; 39 | } { 40 | const protocolState = ProtocolState.getEmptyState(); 41 | 42 | const states: NftParallelClosedMinterState[] = []; 43 | 44 | const state = NftParallelClosedMinterProto.create(nftP2TR, 0n); 45 | const outputState = NftParallelClosedMinterProto.toByteString(state); 46 | protocolState.updateDataList(0, outputState); 47 | states.push(state); 48 | 49 | return { 50 | protocolState, 51 | states, 52 | }; 53 | } 54 | 55 | const buildRevealTx = ( 56 | wallet: WalletService, 57 | genesisId: string, 58 | lockingScript: Buffer, 59 | metadata: CollectionMetadata, 60 | commitTx: btc.Transaction, 61 | feeRate: number, 62 | ): btc.Transaction => { 63 | const { p2tr: minterP2TR } = getMinter( 64 | wallet, 65 | outpoint2ByteString(genesisId), 66 | metadata.max, 67 | ); 68 | 69 | const { tapScript, cblock } = script2P2TR(lockingScript); 70 | const { p2tr: nftP2TR } = getNFTContractP2TR(minterP2TR); 71 | 72 | const { protocolState: txState, states } = getMinterInitialTxState(nftP2TR); 73 | 74 | const revealTx = new btc.Transaction() 75 | .from([ 76 | { 77 | txId: commitTx.id, 78 | outputIndex: 0, 79 | script: commitTx.outputs[0].script, 80 | satoshis: commitTx.outputs[0].satoshis, 81 | }, 82 | { 83 | txId: commitTx.id, 84 | outputIndex: 1, 85 | script: commitTx.outputs[1].script, 86 | satoshis: commitTx.outputs[1].satoshis, 87 | }, 88 | ]) 89 | .addOutput( 90 | new btc.Transaction.Output({ 91 | satoshis: 0, 92 | script: toStateScript(txState), 93 | }), 94 | ); 95 | 96 | for (let i = 0; i < states.length; i++) { 97 | revealTx.addOutput( 98 | new btc.Transaction.Output({ 99 | satoshis: Postage.MINTER_POSTAGE, 100 | script: minterP2TR, 101 | }), 102 | ); 103 | } 104 | 105 | revealTx.feePerByte(feeRate); 106 | 107 | const witnesses: Buffer[] = []; 108 | 109 | const { sighash } = getSHPreimage(revealTx, 0, Buffer.from(tapScript, 'hex')); 110 | 111 | const sig = btc.crypto.Schnorr.sign( 112 | wallet.getTaprootPrivateKey(), 113 | sighash.hash, 114 | ); 115 | 116 | for (let i = 0; i < txState.stateHashList.length; i++) { 117 | const txoStateHash = txState.stateHashList[i]; 118 | witnesses.push(Buffer.from(txoStateHash, 'hex')); 119 | } 120 | witnesses.push(sig); 121 | witnesses.push(lockingScript); 122 | witnesses.push(Buffer.from(cblock, 'hex')); 123 | 124 | const interpreter = new btc.Script.Interpreter(); 125 | const flags = 126 | btc.Script.Interpreter.SCRIPT_VERIFY_WITNESS | 127 | btc.Script.Interpreter.SCRIPT_VERIFY_TAPROOT; 128 | 129 | const res = interpreter.verify( 130 | new btc.Script(''), 131 | commitTx.outputs[0].script, 132 | revealTx, 133 | 0, 134 | flags, 135 | witnesses, 136 | commitTx.outputs[0].satoshis, 137 | ); 138 | 139 | if (!res) { 140 | console.error('reveal faild!', interpreter.errstr); 141 | return; 142 | } 143 | 144 | revealTx.inputs[0].witnesses = witnesses; 145 | 146 | wallet.signTx(revealTx); 147 | return revealTx; 148 | }; 149 | 150 | export async function deploy( 151 | metadata: CollectionMetadata, 152 | feeRate: number, 153 | utxos: UTXO[], 154 | wallet: WalletService, 155 | config: ConfigService, 156 | icon?: { 157 | type: string; 158 | body: string; 159 | }, 160 | ): Promise< 161 | | { 162 | revealTx: btc.Transaction; 163 | genesisTx: btc.Transaction; 164 | tokenId: string; 165 | tokenAddr: string; 166 | minterAddr: string; 167 | } 168 | | undefined 169 | > { 170 | const changeAddress: btc.Address = wallet.getAddress(); 171 | 172 | const pubkeyX = wallet.getXOnlyPublicKey(); 173 | const commitScript = getCatCollectionCommitScript(pubkeyX, metadata, icon); 174 | 175 | const lockingScript = Buffer.from(commitScript, 'hex'); 176 | const { p2tr: p2tr } = script2P2TR(lockingScript); 177 | 178 | const changeScript = btc.Script.fromAddress(changeAddress); 179 | 180 | const commitTx = new btc.Transaction() 181 | .from(utxos) 182 | .addOutput( 183 | new btc.Transaction.Output({ 184 | satoshis: Postage.METADATA_POSTAGE, 185 | script: p2tr, 186 | }), 187 | ) 188 | .addOutput( 189 | /** utxo to pay revealTx fee */ 190 | new btc.Transaction.Output({ 191 | satoshis: 0, 192 | script: changeScript, 193 | }), 194 | ) 195 | .feePerByte(feeRate) 196 | .change(changeAddress); 197 | 198 | if (commitTx.getChangeOutput() === null) { 199 | throw new Error('Insufficient satoshi balance!'); 200 | } 201 | 202 | const dummyGenesisId = `${'0000000000000000000000000000000000000000000000000000000000000000'}_0`; 203 | 204 | const revealTxDummy = buildRevealTx( 205 | wallet, 206 | dummyGenesisId, 207 | lockingScript, 208 | metadata, 209 | commitTx, 210 | feeRate, 211 | ); 212 | 213 | const revealTxFee = 214 | revealTxDummy.vsize * feeRate + 215 | Postage.MINTER_POSTAGE * (revealTxDummy.outputs.length - 1) - 216 | Postage.METADATA_POSTAGE; 217 | 218 | commitTx.outputs[1].satoshis = Math.max(revealTxFee, 546); 219 | 220 | commitTx.change(changeAddress); 221 | 222 | if (commitTx.getChangeOutput() !== null) { 223 | commitTx.getChangeOutput().satoshis -= 2; 224 | } 225 | 226 | wallet.signTx(commitTx); 227 | 228 | const genesisId = `${commitTx.id}_0`; 229 | 230 | const revealTx = buildRevealTx( 231 | wallet, 232 | genesisId, 233 | lockingScript, 234 | metadata, 235 | commitTx, 236 | feeRate, 237 | ); 238 | 239 | const { p2tr: minterP2TR } = getMinter( 240 | wallet, 241 | outpoint2ByteString(genesisId), 242 | metadata.max, 243 | ); 244 | const { p2tr: tokenP2TR } = getNFTContractP2TR(minterP2TR); 245 | 246 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 247 | const commitTxId = await broadcast( 248 | config, 249 | wallet, 250 | commitTx.uncheckedSerialize(), 251 | ); 252 | 253 | if (commitTxId instanceof Error) { 254 | logerror(`commit failed!`, commitTxId); 255 | return null; 256 | } 257 | 258 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 259 | const revealTxId = await broadcast( 260 | config, 261 | wallet, 262 | revealTx.uncheckedSerialize(), 263 | ); 264 | 265 | if (revealTxId instanceof Error) { 266 | logerror(`reveal failed!`, revealTxId); 267 | return null; 268 | } 269 | 270 | return { 271 | tokenId: genesisId, 272 | tokenAddr: p2tr2Address(tokenP2TR, config.getNetwork()), 273 | minterAddr: p2tr2Address(minterP2TR, config.getNetwork()), 274 | genesisTx: commitTx, 275 | revealTx: revealTx, 276 | }; 277 | } 278 | -------------------------------------------------------------------------------- /src/commands/mint/mint.command.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from 'nest-commander'; 2 | import { 3 | getUtxos, 4 | logerror, 5 | btc, 6 | isNFTOpenMinter, 7 | isNFTClosedMinter, 8 | getNFTMinter, 9 | toTokenAddress, 10 | isNFTParallelClosedMinter, 11 | NFTClosedMinterContract, 12 | NFTParallelClosedMinterContract, 13 | NFTOpenMinterContract, 14 | sleep, 15 | getNFTClosedMinters, 16 | waitConfirm, 17 | } from 'src/common'; 18 | import { ConfigService, SpendService, WalletService } from 'src/providers'; 19 | import { Inject } from '@nestjs/common'; 20 | import { findCollectionInfoById } from 'src/collection'; 21 | import { 22 | BoardcastCommand, 23 | BoardcastCommandOptions, 24 | } from '../boardcast.command'; 25 | import { isAbsolute, join } from 'path'; 26 | import { accessSync, constants, existsSync, readFileSync } from 'fs'; 27 | import { generateCollectionMerkleTree } from '../deploy/nft.open-mint'; 28 | import { openMint } from './nft.open-mint'; 29 | import { closedMint } from './nft.closed-mint'; 30 | import { parallelClosedMint } from './nft.parallel-closed-mint'; 31 | import { feeSplitTx } from './split'; 32 | 33 | interface MintCommandOptions extends BoardcastCommandOptions { 34 | id: string; 35 | resource: string; 36 | owner?: string; 37 | type?: string; 38 | } 39 | 40 | @Command({ 41 | name: 'mint', 42 | description: 'Mint a NFT token', 43 | }) 44 | export class MintCommand extends BoardcastCommand { 45 | constructor( 46 | @Inject() private readonly spendService: SpendService, 47 | @Inject() protected readonly walletService: WalletService, 48 | @Inject() protected readonly configService: ConfigService, 49 | ) { 50 | super(spendService, walletService, configService); 51 | } 52 | 53 | async cat_cli_run( 54 | passedParams: string[], 55 | options?: MintCommandOptions, 56 | ): Promise { 57 | try { 58 | if (!options.id) { 59 | throw new Error('expect a ID option'); 60 | } 61 | 62 | const resourceDir = options.resource 63 | ? options.resource 64 | : join(process.cwd(), 'resource'); 65 | 66 | const contentType = options.type || 'image/png'; 67 | 68 | const address = this.walletService.getAddress(); 69 | const collectionInfo = await findCollectionInfoById( 70 | this.configService, 71 | options.id, 72 | ); 73 | 74 | if (!collectionInfo) { 75 | console.error( 76 | `No NFT collection info found for collectionId: ${options.id}`, 77 | ); 78 | return; 79 | } 80 | 81 | const feeRate = await this.getFeeRate(); 82 | const feeUtxos = await this.getFeeUTXOs(address); 83 | 84 | if (feeUtxos.length === 0) { 85 | console.warn('Insufficient satoshis balance!'); 86 | return; 87 | } 88 | 89 | const pubkeyX = this.walletService.getXOnlyPublicKey(); 90 | const collectionMerkleTree = isNFTOpenMinter( 91 | collectionInfo.metadata.minterMd5, 92 | ) 93 | ? generateCollectionMerkleTree( 94 | collectionInfo.metadata.max, 95 | pubkeyX, 96 | contentType, 97 | resourceDir, 98 | ) 99 | : undefined; 100 | 101 | const minter = await getNFTMinter( 102 | this.configService, 103 | collectionInfo, 104 | this.spendSerivce, 105 | collectionMerkleTree, 106 | ); 107 | 108 | if (minter == null) { 109 | console.error( 110 | `no NFT [${collectionInfo.metadata.symbol}] minter found`, 111 | ); 112 | return; 113 | } 114 | 115 | const contentBody = this.readNFTFile( 116 | resourceDir, 117 | minter.state.data.nextLocalId, 118 | contentType, 119 | ); 120 | 121 | const metadata = this.readMetaData( 122 | resourceDir, 123 | minter.state.data.nextLocalId, 124 | ); 125 | 126 | if (isNFTOpenMinter(collectionInfo.metadata.minterMd5)) { 127 | const res = await openMint( 128 | this.configService, 129 | this.walletService, 130 | this.spendSerivce, 131 | feeRate, 132 | feeUtxos, 133 | collectionInfo, 134 | minter as unknown as NFTOpenMinterContract, 135 | collectionMerkleTree, 136 | contentType, 137 | contentBody, 138 | metadata, 139 | options.owner, 140 | ); 141 | 142 | console.log( 143 | `Minting ${collectionInfo.metadata.symbol}:${minter.state.data.nextLocalId} NFT in txid: ${res} ...`, 144 | ); 145 | } else if (isNFTClosedMinter(collectionInfo.metadata.minterMd5)) { 146 | while (true) { 147 | const minters = await getNFTClosedMinters( 148 | this.configService, 149 | collectionInfo, 150 | this.spendSerivce, 151 | ); 152 | 153 | if (minters.length == 0) { 154 | console.warn( 155 | `no NFT [${collectionInfo.metadata.symbol}] minter found`, 156 | ); 157 | await sleep(5); 158 | continue; 159 | } 160 | 161 | console.log( 162 | `Total: ${minters.length} ${collectionInfo.metadata.symbol} minters found`, 163 | ); 164 | 165 | const feeRate = await this.getFeeRate(); 166 | let feeUtxos = await this.getFeeUTXOs(address); 167 | 168 | if (feeUtxos.length == 0) { 169 | console.error(`no feeUtxos found`); 170 | return; 171 | } 172 | 173 | if (feeUtxos.length < minters.length) { 174 | console.warn( 175 | `too less feeUtxos ${feeUtxos.length}, require ${minters.length}`, 176 | ); 177 | 178 | const { newfeeUtxos, txId } = await feeSplitTx( 179 | this.configService, 180 | this.walletService, 181 | feeUtxos, 182 | feeRate, 183 | minters.length, 184 | ); 185 | 186 | await waitConfirm(this.configService, txId); 187 | feeUtxos = newfeeUtxos; 188 | } 189 | 190 | const newFeeUtxos = feeUtxos.slice(); 191 | 192 | await Promise.all( 193 | minters.map(async (_, i) => { 194 | let minter = minters[i]; 195 | let vSizeAcc = 0; 196 | 197 | let feeUtxo = feeUtxos[i]; 198 | for (let j = 0; j < 12; j++) { 199 | const owner = options.owner; 200 | 201 | const contentBody = this.readNFTFile( 202 | resourceDir, 203 | minter.state.data.nextLocalId, 204 | contentType, 205 | ); 206 | 207 | const metadata = this.readMetaData( 208 | resourceDir, 209 | minter.state.data.nextLocalId, 210 | ); 211 | 212 | const res = await closedMint( 213 | this.configService, 214 | this.walletService, 215 | this.spendSerivce, 216 | feeRate, 217 | [feeUtxo], 218 | collectionInfo, 219 | minter, 220 | contentType, 221 | contentBody, 222 | metadata, 223 | owner, 224 | ); 225 | 226 | if (res instanceof Error) { 227 | throw res; 228 | } 229 | 230 | const { 231 | newFeeUTXO, 232 | revealTxId, 233 | minter: newMinter, 234 | vSize, 235 | } = res; 236 | 237 | vSizeAcc += vSize; 238 | 239 | console.log( 240 | `Minting ${collectionInfo.metadata.symbol}:${minter.state.data.nextLocalId} NFT in txid: ${revealTxId} ...`, 241 | ); 242 | minter = newMinter; 243 | feeUtxo = newFeeUTXO; 244 | newFeeUtxos[i] = newFeeUTXO; 245 | if (newMinter === null) { 246 | break; 247 | } 248 | 249 | if (vSizeAcc + vSize > 100_000) { 250 | break; 251 | } 252 | } 253 | await waitConfirm(this.configService, newFeeUtxos[i].txId); 254 | }), 255 | ); 256 | 257 | await sleep(5); 258 | } 259 | } else if (isNFTParallelClosedMinter(collectionInfo.metadata.minterMd5)) { 260 | const contentBody = this.readNFTFile( 261 | resourceDir, 262 | minter.state.data.nextLocalId, 263 | contentType, 264 | ); 265 | 266 | const metadata = this.readMetaData( 267 | resourceDir, 268 | minter.state.data.nextLocalId, 269 | ); 270 | 271 | const res = await parallelClosedMint( 272 | this.configService, 273 | this.walletService, 274 | this.spendSerivce, 275 | feeRate, 276 | feeUtxos, 277 | collectionInfo, 278 | minter as NFTParallelClosedMinterContract, 279 | contentType, 280 | contentBody, 281 | metadata, 282 | options.owner, 283 | ); 284 | 285 | if (res instanceof Error) { 286 | throw res; 287 | } 288 | 289 | console.log( 290 | `Minting ${collectionInfo.metadata.symbol}:${minter.state.data.nextLocalId} NFT in txid: ${res} ...`, 291 | ); 292 | } else { 293 | throw new Error('Unkown minter'); 294 | } 295 | } catch (error) { 296 | logerror('mint failed!', error); 297 | } 298 | } 299 | 300 | @Option({ 301 | flags: '-i, --id [tokenId]', 302 | description: 'ID of the token', 303 | }) 304 | parseId(val: string): string { 305 | return val; 306 | } 307 | 308 | @Option({ 309 | flags: '-r, --resource [resource]', 310 | description: 'resource of the minted nft token', 311 | }) 312 | parseResource(val: string): string { 313 | if (!val) { 314 | logerror("resource can't be empty!", new Error()); 315 | process.exit(0); 316 | } 317 | 318 | const resource = isAbsolute(val) ? val : join(process.cwd(), val); 319 | 320 | try { 321 | accessSync(resource, constants.R_OK); 322 | return resource; 323 | } catch (error) { 324 | logerror(`can\'t access resource file: ${resource}`, error); 325 | process.exit(0); 326 | } 327 | } 328 | 329 | @Option({ 330 | flags: '-t, --type [type]', 331 | description: 'content type of the resource', 332 | }) 333 | parseType(val: string): string { 334 | if (!val) { 335 | logerror("resource can't be empty!", new Error()); 336 | process.exit(0); 337 | } 338 | 339 | return val; 340 | } 341 | 342 | @Option({ 343 | flags: '-o, --owner [owner]', 344 | description: 'mint nft into a owner', 345 | }) 346 | parseOwner(val: string): string { 347 | if (!val) { 348 | logerror("owner can't be empty!", new Error()); 349 | process.exit(0); 350 | } 351 | const HEX_Exp = /^#[0-9A-Fa-f]{20}$/i; 352 | if (HEX_Exp.test(val)) { 353 | return val; 354 | } 355 | 356 | let receiver: btc.Address; 357 | try { 358 | receiver = btc.Address.fromString(val); 359 | 360 | if ( 361 | receiver.type === 'taproot' || 362 | receiver.type === 'witnesspubkeyhash' 363 | ) { 364 | return toTokenAddress(receiver); 365 | } else { 366 | console.error(`Invalid owner address type: "${receiver.type}" `); 367 | } 368 | } catch (error) { 369 | console.error(`Invalid owner address: "${val}" `); 370 | } 371 | return; 372 | } 373 | 374 | @Option({ 375 | flags: '-s, --stop [stopId]', 376 | description: 'stop minting at a localId', 377 | }) 378 | parseStopId(val: string): bigint { 379 | if (!val) { 380 | logerror("owner can't be empty!", new Error()); 381 | process.exit(0); 382 | } 383 | 384 | return BigInt(val); 385 | } 386 | 387 | async getFeeUTXOs(address: btc.Address) { 388 | let feeUtxos = await getUtxos( 389 | this.configService, 390 | this.walletService, 391 | address, 392 | ); 393 | 394 | feeUtxos = feeUtxos.filter((utxo) => { 395 | return this.spendService.isUnspent(utxo); 396 | }); 397 | return feeUtxos; 398 | } 399 | 400 | readNFTFile(resource: string, localId: bigint, type: string) { 401 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 402 | const [_, ext] = type.split('/'); 403 | if (!ext) { 404 | throw new Error(`unknow type: ${type}`); 405 | } 406 | return readFileSync(join(resource, `${localId}.${ext}`)).toString('hex'); 407 | } 408 | 409 | readMetaData(resource: string, localId: bigint): object | undefined { 410 | const metadata = { 411 | localId: localId, 412 | }; 413 | 414 | try { 415 | const metadataFile = join(resource, `${localId}.json`); 416 | 417 | if (existsSync(metadataFile)) { 418 | const str = readFileSync(metadataFile).toString(); 419 | const obj = JSON.parse(str); 420 | Object.assign(metadata, obj); 421 | } 422 | } catch (error) { 423 | logerror(`readMetaData FAIL, localId: ${localId}`, error); 424 | } 425 | return metadata; 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /src/commands/mint/nft.closed-mint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | toByteString, 3 | UTXO, 4 | MethodCallOptions, 5 | int2ByteString, 6 | SmartContract, 7 | } from 'scrypt-ts'; 8 | import { 9 | getRawTransaction, 10 | getDummySigner, 11 | getDummyUTXO, 12 | callToBufferList, 13 | broadcast, 14 | resetTx, 15 | toStateScript, 16 | outpoint2ByteString, 17 | Postage, 18 | toP2tr, 19 | logerror, 20 | btc, 21 | verifyContract, 22 | CollectionInfo, 23 | NFTClosedMinterContract, 24 | getNftClosedMinterContractP2TR, 25 | script2P2TR, 26 | } from 'src/common'; 27 | 28 | import { 29 | getBackTraceInfo, 30 | ProtocolState, 31 | PreTxStatesInfo, 32 | ChangeInfo, 33 | NftClosedMinterProto, 34 | NftClosedMinterState, 35 | CAT721State, 36 | CAT721Proto, 37 | getTxCtxMulti, 38 | NftClosedMinter, 39 | } from '@cat-protocol/cat-smartcontracts'; 40 | import { ConfigService, SpendService, WalletService } from 'src/providers'; 41 | import { createNft, unlockNFT } from './nft'; 42 | 43 | const calcVsize = async ( 44 | wallet: WalletService, 45 | minter: SmartContract, 46 | newState: ProtocolState, 47 | tokenMint: CAT721State, 48 | preTxState: PreTxStatesInfo, 49 | preState: NftClosedMinterState, 50 | minterTapScript: string, 51 | nftTapScript: string, 52 | inputIndex: number, 53 | revealTx: btc.Transaction, 54 | commitTx: btc.Transaction, 55 | changeScript: btc.Script, 56 | nftCommitScript: btc.Script, 57 | backtraceInfo: any, 58 | cblockMinter: string, 59 | cblocknft: string, 60 | ) => { 61 | const txCtxs = getTxCtxMulti( 62 | revealTx, 63 | [0, 1], 64 | [Buffer.from(minterTapScript, 'hex'), Buffer.from(nftTapScript, 'hex')], 65 | ); 66 | unlockNFT(wallet, commitTx, revealTx, nftCommitScript, cblocknft, txCtxs[1]); 67 | 68 | const { sighash, shPreimage, prevoutsCtx, spentScripts } = txCtxs[0]; 69 | 70 | const changeInfo: ChangeInfo = { 71 | script: toByteString(changeScript.toHex()), 72 | satoshis: int2ByteString(BigInt(0n), 8n), 73 | }; 74 | const sig = btc.crypto.Schnorr.sign( 75 | wallet.getTokenPrivateKey(), 76 | sighash.hash, 77 | ); 78 | const minterCall = await minter.methods.mint( 79 | newState.stateHashList, 80 | tokenMint, 81 | wallet.getPubKeyPrefix(), 82 | wallet.getXOnlyPublicKey(), 83 | () => sig.toString('hex'), 84 | int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n), 85 | int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n), 86 | preState, 87 | preTxState, 88 | backtraceInfo, 89 | shPreimage, 90 | prevoutsCtx, 91 | spentScripts, 92 | changeInfo, 93 | { 94 | fromUTXO: getDummyUTXO(), 95 | verify: false, 96 | exec: false, 97 | } as MethodCallOptions, 98 | ); 99 | const witnesses = [ 100 | ...callToBufferList(minterCall), 101 | minter.lockingScript.toBuffer(), 102 | Buffer.from(cblockMinter, 'hex'), 103 | ]; 104 | revealTx.inputs[inputIndex].witnesses = witnesses; 105 | wallet.signTx(revealTx); 106 | const vsize = revealTx.vsize; 107 | resetTx(revealTx); 108 | return vsize; 109 | }; 110 | 111 | export async function closedMint( 112 | config: ConfigService, 113 | wallet: WalletService, 114 | spendService: SpendService, 115 | feeRate: number, 116 | feeUtxos: UTXO[], 117 | collectionInfo: CollectionInfo, 118 | minterContract: NFTClosedMinterContract, 119 | contentType: string, 120 | contentBody: string, 121 | nftmetadata: object, 122 | owner: string = '', 123 | ): Promise< 124 | | { 125 | revealTxId: string; 126 | minter: NFTClosedMinterContract | null; 127 | newFeeUTXO: UTXO; 128 | vSize: number; 129 | } 130 | | Error 131 | > { 132 | const { 133 | utxo: minterUtxo, 134 | state: { protocolState, data: preState }, 135 | } = minterContract; 136 | 137 | const address = wallet.getAddress(); 138 | 139 | owner = owner || wallet.getTokenAddress(); 140 | 141 | const tokenP2TR = toP2tr(collectionInfo.collectionAddr); 142 | 143 | const { commitTx, feeUTXO, nftCommitScript } = createNft( 144 | wallet, 145 | feeRate, 146 | feeUtxos, 147 | address, 148 | contentType, 149 | contentBody, 150 | nftmetadata, 151 | ); 152 | 153 | const genesisId = outpoint2ByteString(collectionInfo.collectionId); 154 | 155 | const newState = ProtocolState.getEmptyState(); 156 | 157 | const newNextLocalId = preState.nextLocalId + 1n; 158 | const tokenState = CAT721Proto.create(owner, preState.nextLocalId); 159 | let minterState: NftClosedMinterState | null = null; 160 | if (newNextLocalId < preState.quotaMaxLocalId) { 161 | minterState = NftClosedMinterProto.create( 162 | tokenP2TR, 163 | preState.quotaMaxLocalId, 164 | newNextLocalId, 165 | ); 166 | newState.updateDataList(0, NftClosedMinterProto.toByteString(minterState)); 167 | const tokenState = CAT721Proto.create(owner, preState.nextLocalId); 168 | newState.updateDataList(1, CAT721Proto.toByteString(tokenState)); 169 | } else { 170 | newState.updateDataList(0, CAT721Proto.toByteString(tokenState)); 171 | } 172 | 173 | const issuerAddress = wallet.getTokenAddress(); 174 | const { 175 | tapScript: minterTapScript, 176 | cblock: cblockMinter, 177 | contract: minter, 178 | } = getNftClosedMinterContractP2TR( 179 | issuerAddress, 180 | genesisId, 181 | collectionInfo.metadata.max, 182 | ); 183 | 184 | const { tapScript: nftTapScript, cblock: cblockNft } = 185 | script2P2TR(nftCommitScript); 186 | 187 | const changeScript = btc.Script.fromAddress(address); 188 | 189 | const nftUTXO: UTXO = { 190 | txId: commitTx.id, 191 | outputIndex: 0, 192 | satoshis: commitTx.outputs[0].satoshis, 193 | script: commitTx.outputs[0].script.toHex(), 194 | }; 195 | 196 | const revealTx = new btc.Transaction() 197 | .from([minterUtxo, nftUTXO, feeUTXO]) 198 | .addOutput( 199 | new btc.Transaction.Output({ 200 | satoshis: 0, 201 | script: toStateScript(newState), 202 | }), 203 | ); 204 | 205 | if (newNextLocalId < preState.quotaMaxLocalId) { 206 | revealTx.addOutput( 207 | new btc.Transaction.Output({ 208 | script: new btc.Script(minterUtxo.script), 209 | satoshis: Postage.MINTER_POSTAGE, 210 | }), 211 | ); 212 | } 213 | 214 | revealTx 215 | .addOutput( 216 | new btc.Transaction.Output({ 217 | satoshis: Postage.TOKEN_POSTAGE, 218 | script: tokenP2TR, 219 | }), 220 | ) 221 | .addOutput( 222 | new btc.Transaction.Output({ 223 | satoshis: 0, 224 | script: changeScript, 225 | }), 226 | ) 227 | .feePerByte(feeRate); 228 | 229 | const minterInputIndex = 0; 230 | 231 | const prevTxHex = await getRawTransaction(config, minterUtxo.txId); 232 | if (prevTxHex instanceof Error) { 233 | logerror(`get raw transaction ${minterUtxo.txId} failed!`, prevTxHex); 234 | return prevTxHex; 235 | } 236 | 237 | const prevTx = new btc.Transaction(prevTxHex); 238 | 239 | const prevPrevTxId = prevTx.inputs[minterInputIndex].prevTxId.toString('hex'); 240 | const prevPrevTxHex = await getRawTransaction(config, prevPrevTxId); 241 | if (prevPrevTxHex instanceof Error) { 242 | logerror(`get raw transaction ${prevPrevTxId} failed!`, prevPrevTxHex); 243 | return prevPrevTxHex; 244 | } 245 | 246 | const prevPrevTx = new btc.Transaction(prevPrevTxHex); 247 | 248 | const backtraceInfo = getBackTraceInfo(prevTx, prevPrevTx, minterInputIndex); 249 | 250 | await minter.connect(getDummySigner()); 251 | 252 | const preTxState: PreTxStatesInfo = { 253 | statesHashRoot: protocolState.hashRoot, 254 | txoStateHashes: protocolState.stateHashList, 255 | }; 256 | 257 | const vsize: number = await calcVsize( 258 | wallet, 259 | minter, 260 | newState, 261 | tokenState, 262 | preTxState, 263 | preState, 264 | minterTapScript, 265 | nftTapScript, 266 | minterInputIndex, 267 | revealTx, 268 | commitTx, 269 | changeScript, 270 | nftCommitScript, 271 | backtraceInfo, 272 | cblockMinter, 273 | cblockNft, 274 | ); 275 | 276 | const changeAmount = 277 | revealTx.inputAmount - 278 | vsize * feeRate - 279 | Postage.MINTER_POSTAGE * 280 | (newNextLocalId < collectionInfo.metadata.max ? 1 : 0) - 281 | Postage.TOKEN_POSTAGE; 282 | 283 | if (changeAmount < 546) { 284 | const message = 'Insufficient satoshis balance!'; 285 | return new Error(message); 286 | } 287 | 288 | // update change amount 289 | const changeOutputIndex = revealTx.outputs.length - 1; 290 | revealTx.outputs[changeOutputIndex].satoshis = changeAmount; 291 | 292 | const txCtxs = getTxCtxMulti( 293 | revealTx, 294 | [minterInputIndex, minterInputIndex + 1], 295 | [Buffer.from(minterTapScript, 'hex'), Buffer.from(nftTapScript, 'hex')], 296 | ); 297 | 298 | const changeInfo: ChangeInfo = { 299 | script: toByteString(changeScript.toHex()), 300 | satoshis: int2ByteString(BigInt(changeAmount), 8n), 301 | }; 302 | 303 | const { shPreimage, prevoutsCtx, spentScripts, sighash } = 304 | txCtxs[minterInputIndex]; 305 | 306 | const sig = btc.crypto.Schnorr.sign( 307 | wallet.getTokenPrivateKey(), 308 | sighash.hash, 309 | ); 310 | 311 | const minterCall = await minter.methods.mint( 312 | newState.stateHashList, 313 | tokenState, 314 | wallet.getPubKeyPrefix(), 315 | wallet.getXOnlyPublicKey(), 316 | () => sig.toString('hex'), 317 | int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n), 318 | int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n), 319 | preState, 320 | preTxState, 321 | backtraceInfo, 322 | shPreimage, 323 | prevoutsCtx, 324 | spentScripts, 325 | changeInfo, 326 | { 327 | fromUTXO: getDummyUTXO(), 328 | verify: false, 329 | exec: false, 330 | } as MethodCallOptions, 331 | ); 332 | const witnesses = [ 333 | ...callToBufferList(minterCall), 334 | minter.lockingScript.toBuffer(), 335 | Buffer.from(cblockMinter, 'hex'), 336 | ]; 337 | revealTx.inputs[minterInputIndex].witnesses = witnesses; 338 | 339 | if (config.getVerify()) { 340 | const res = verifyContract( 341 | minterUtxo, 342 | revealTx, 343 | minterInputIndex, 344 | witnesses, 345 | ); 346 | if (typeof res === 'string') { 347 | console.log('unlocking minter failed:', res); 348 | return new Error('unlocking minter failed'); 349 | } 350 | } 351 | if ( 352 | !unlockNFT( 353 | wallet, 354 | commitTx, 355 | revealTx, 356 | nftCommitScript, 357 | cblockNft, 358 | txCtxs[minterInputIndex + 1], 359 | config.getVerify(), 360 | ) 361 | ) { 362 | return new Error('unlock NFT commit UTXO failed'); 363 | } 364 | 365 | wallet.signTx(revealTx); 366 | 367 | let res = await broadcast(config, wallet, commitTx.uncheckedSerialize()); 368 | 369 | if (res instanceof Error) { 370 | logerror('broadcast commit NFT tx failed!', res); 371 | return res; 372 | } 373 | 374 | console.log( 375 | `Commiting ${collectionInfo.metadata.symbol}:${minterContract.state.data.nextLocalId} NFT in txid: ${res}`, 376 | ); 377 | spendService.updateSpends(commitTx); 378 | 379 | res = await broadcast(config, wallet, revealTx.uncheckedSerialize()); 380 | 381 | if (res instanceof Error) { 382 | logerror('broadcast reveal NFT tx failed!', res); 383 | return res; 384 | } 385 | 386 | spendService.updateSpends(revealTx); 387 | 388 | const newMinter: NFTClosedMinterContract | null = 389 | minterState === null 390 | ? null 391 | : { 392 | utxo: { 393 | txId: revealTx.id, 394 | outputIndex: 1, 395 | satoshis: revealTx.outputs[1].satoshis, 396 | script: revealTx.outputs[1].script.toHex(), 397 | }, 398 | state: { 399 | protocolState: newState, 400 | data: minterState, 401 | }, 402 | }; 403 | return { 404 | revealTxId: revealTx.id, 405 | minter: newMinter, 406 | vSize: commitTx.vsize + revealTx.vsize, 407 | newFeeUTXO: { 408 | txId: revealTx.id, 409 | outputIndex: changeOutputIndex, 410 | satoshis: revealTx.outputs[changeOutputIndex].satoshis, 411 | script: revealTx.outputs[changeOutputIndex].script.toHex(), 412 | }, 413 | }; 414 | } 415 | -------------------------------------------------------------------------------- /src/commands/mint/nft.open-mint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | toByteString, 3 | UTXO, 4 | MethodCallOptions, 5 | int2ByteString, 6 | SmartContract, 7 | } from 'scrypt-ts'; 8 | import { 9 | getRawTransaction, 10 | getDummySigner, 11 | getDummyUTXO, 12 | callToBufferList, 13 | broadcast, 14 | resetTx, 15 | toStateScript, 16 | outpoint2ByteString, 17 | Postage, 18 | toP2tr, 19 | logerror, 20 | btc, 21 | verifyContract, 22 | CollectionInfo, 23 | NFTOpenMinterContract, 24 | getNftOpenMinterContractP2TR, 25 | script2P2TR, 26 | log, 27 | } from 'src/common'; 28 | 29 | import { 30 | getBackTraceInfo, 31 | OpenMinter, 32 | ProtocolState, 33 | PreTxStatesInfo, 34 | ChangeInfo, 35 | NftOpenMinterProto, 36 | NftOpenMinterState, 37 | CAT721State, 38 | CAT721Proto, 39 | NftOpenMinterMerkleTreeData, 40 | NftMerkleLeaf, 41 | NftOpenMinter, 42 | int32, 43 | getTxCtxMulti, 44 | } from '@cat-protocol/cat-smartcontracts'; 45 | import { ConfigService, SpendService, WalletService } from 'src/providers'; 46 | import { createNft, unlockNFT } from './nft'; 47 | 48 | const calcVsize = async ( 49 | wallet: WalletService, 50 | minter: SmartContract, 51 | newState: ProtocolState, 52 | tokenMint: CAT721State, 53 | leafInfo: any, 54 | preTxState: PreTxStatesInfo, 55 | preState: NftOpenMinterState, 56 | minterTapScript: string, 57 | nftTapScript: string, 58 | inputIndex: number, 59 | revealTx: btc.Transaction, 60 | commitTx: btc.Transaction, 61 | changeScript: btc.Script, 62 | nftCommitScript: btc.Script, 63 | backtraceInfo: any, 64 | cblockMinter: string, 65 | cblocknft: string, 66 | ) => { 67 | const txCtxs = getTxCtxMulti( 68 | revealTx, 69 | [0, 1], 70 | [Buffer.from(minterTapScript, 'hex'), Buffer.from(nftTapScript, 'hex')], 71 | ); 72 | 73 | unlockNFT(wallet, commitTx, revealTx, nftCommitScript, cblocknft, txCtxs[1]); 74 | 75 | const { sighash, shPreimage, prevoutsCtx, spentScripts } = txCtxs[0]; 76 | 77 | const changeInfo: ChangeInfo = { 78 | script: toByteString(changeScript.toHex()), 79 | satoshis: int2ByteString(BigInt(0n), 8n), 80 | }; 81 | const sig = btc.crypto.Schnorr.sign( 82 | wallet.getTokenPrivateKey(), 83 | sighash.hash, 84 | ); 85 | const minterCall = await minter.methods.mint( 86 | newState.stateHashList, 87 | tokenMint, 88 | leafInfo.neighbor, 89 | leafInfo.neighborType, 90 | wallet.getPubKeyPrefix(), 91 | wallet.getXOnlyPublicKey(), 92 | () => sig.toString('hex'), 93 | int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n), 94 | int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n), 95 | preState, 96 | preTxState, 97 | backtraceInfo, 98 | shPreimage, 99 | prevoutsCtx, 100 | spentScripts, 101 | changeInfo, 102 | { 103 | fromUTXO: getDummyUTXO(), 104 | verify: false, 105 | exec: false, 106 | } as MethodCallOptions, 107 | ); 108 | const witnesses = [ 109 | ...callToBufferList(minterCall), 110 | minter.lockingScript.toBuffer(), 111 | Buffer.from(cblockMinter, 'hex'), 112 | ]; 113 | revealTx.inputs[inputIndex].witnesses = witnesses; 114 | wallet.signTx(revealTx); 115 | const vsize = revealTx.vsize; 116 | resetTx(revealTx); 117 | return vsize; 118 | }; 119 | 120 | const getPremineAddress = async ( 121 | config: ConfigService, 122 | utxo: UTXO, 123 | ): Promise => { 124 | const txhex = await getRawTransaction(config, utxo.txId); 125 | if (txhex instanceof Error) { 126 | logerror(`get raw transaction ${utxo.txId} failed!`, txhex); 127 | return txhex; 128 | } 129 | try { 130 | const tx = new btc.Transaction(txhex); 131 | const witnesses: Buffer[] = tx.inputs[0].getWitnesses(); 132 | const lockingScript = witnesses[witnesses.length - 2]; 133 | try { 134 | const minter = NftOpenMinter.fromLockingScript( 135 | lockingScript.toString('hex'), 136 | ) as NftOpenMinter; 137 | return minter.premineAddr; 138 | } catch (e) {} 139 | const minter = NftOpenMinter.fromLockingScript( 140 | lockingScript.toString('hex'), 141 | ) as NftOpenMinter; 142 | return minter.premineAddr; 143 | } catch (error) { 144 | return error; 145 | } 146 | }; 147 | 148 | export async function openMint( 149 | config: ConfigService, 150 | wallet: WalletService, 151 | spendService: SpendService, 152 | feeRate: number, 153 | feeUtxos: UTXO[], 154 | collectionInfo: CollectionInfo, 155 | minterContract: NFTOpenMinterContract, 156 | nftOpenMinterMerkleTreeData: NftOpenMinterMerkleTreeData, 157 | contentType: string, 158 | contentBody: string, 159 | nftmetadata: object, 160 | owner?: string, 161 | ): Promise { 162 | const { 163 | utxo: minterUtxo, 164 | state: { protocolState, data: preState }, 165 | } = minterContract; 166 | 167 | const address = wallet.getAddress(); 168 | 169 | owner = owner || wallet.getTokenAddress(); 170 | 171 | const tokenP2TR = toP2tr(collectionInfo.collectionAddr); 172 | 173 | const { commitTx, feeUTXO, nftCommitScript } = createNft( 174 | wallet, 175 | feeRate, 176 | feeUtxos, 177 | address, 178 | contentType, 179 | contentBody, 180 | nftmetadata, 181 | ); 182 | 183 | const genesisId = outpoint2ByteString(collectionInfo.collectionId); 184 | 185 | const newState = ProtocolState.getEmptyState(); 186 | 187 | const newNextLocalId = preState.nextLocalId + 1n; 188 | 189 | const oldLeaf = nftOpenMinterMerkleTreeData.getLeaf( 190 | Number(preState.nextLocalId), 191 | ); 192 | const newLeaf: NftMerkleLeaf = { 193 | commitScript: oldLeaf.commitScript, 194 | localId: oldLeaf.localId, 195 | isMined: true, 196 | }; 197 | nftOpenMinterMerkleTreeData.updateLeaf(newLeaf, Number(preState.nextLocalId)); 198 | const tokenState = CAT721Proto.create(owner, preState.nextLocalId); 199 | 200 | if (newNextLocalId < collectionInfo.metadata.max) { 201 | const minterState = NftOpenMinterProto.create( 202 | tokenP2TR, 203 | nftOpenMinterMerkleTreeData.merkleRoot, 204 | newNextLocalId, 205 | ); 206 | newState.updateDataList(0, NftOpenMinterProto.toByteString(minterState)); 207 | newState.updateDataList(1, CAT721Proto.toByteString(tokenState)); 208 | } else { 209 | newState.updateDataList(0, CAT721Proto.toByteString(tokenState)); 210 | } 211 | 212 | let premineAddress = ''; 213 | 214 | if (collectionInfo.metadata.premine > 0n) { 215 | if (preState.nextLocalId === 0n) { 216 | premineAddress = wallet.getTokenAddress(); 217 | } else { 218 | const address = await getPremineAddress(config, minterContract.utxo); 219 | 220 | if (address instanceof Error) { 221 | logerror(`get premine address failed!`, address); 222 | return address; 223 | } 224 | 225 | premineAddress = address; 226 | } 227 | } 228 | 229 | const { 230 | tapScript: minterTapScript, 231 | cblock: cblockMinter, 232 | contract: minter, 233 | } = getNftOpenMinterContractP2TR( 234 | genesisId, 235 | collectionInfo.metadata.max, 236 | collectionInfo.metadata.premine || 0n, 237 | premineAddress, 238 | ); 239 | 240 | const { tapScript: nftTapScript, cblock: cblockNft } = 241 | script2P2TR(nftCommitScript); 242 | 243 | const changeScript = btc.Script.fromAddress(address); 244 | 245 | const nftUTXO: UTXO = { 246 | txId: commitTx.id, 247 | outputIndex: 0, 248 | satoshis: commitTx.outputs[0].satoshis, 249 | script: commitTx.outputs[0].script.toHex(), 250 | }; 251 | 252 | const revealTx = new btc.Transaction() 253 | .from([minterUtxo, nftUTXO, feeUTXO]) 254 | .addOutput( 255 | new btc.Transaction.Output({ 256 | satoshis: 0, 257 | script: toStateScript(newState), 258 | }), 259 | ); 260 | 261 | if (newNextLocalId < collectionInfo.metadata.max) { 262 | revealTx.addOutput( 263 | new btc.Transaction.Output({ 264 | script: new btc.Script(minterUtxo.script), 265 | satoshis: Postage.MINTER_POSTAGE, 266 | }), 267 | ); 268 | } 269 | 270 | revealTx 271 | .addOutput( 272 | new btc.Transaction.Output({ 273 | satoshis: Postage.TOKEN_POSTAGE, 274 | script: tokenP2TR, 275 | }), 276 | ) 277 | .addOutput( 278 | new btc.Transaction.Output({ 279 | satoshis: 0, 280 | script: changeScript, 281 | }), 282 | ) 283 | .feePerByte(feeRate); 284 | 285 | const minterInputIndex = 0; 286 | 287 | const prevTxHex = await getRawTransaction(config, minterUtxo.txId); 288 | if (prevTxHex instanceof Error) { 289 | logerror(`get raw transaction ${minterUtxo.txId} failed!`, prevTxHex); 290 | return prevTxHex; 291 | } 292 | 293 | const prevTx = new btc.Transaction(prevTxHex); 294 | 295 | const prevPrevTxId = prevTx.inputs[minterInputIndex].prevTxId.toString('hex'); 296 | const prevPrevTxHex = await getRawTransaction(config, prevPrevTxId); 297 | if (prevPrevTxHex instanceof Error) { 298 | logerror(`get raw transaction ${prevPrevTxId} failed!`, prevPrevTxHex); 299 | return prevPrevTxHex; 300 | } 301 | 302 | const prevPrevTx = new btc.Transaction(prevPrevTxHex); 303 | 304 | const backtraceInfo = getBackTraceInfo(prevTx, prevPrevTx, minterInputIndex); 305 | 306 | await minter.connect(getDummySigner()); 307 | 308 | const preTxState: PreTxStatesInfo = { 309 | statesHashRoot: protocolState.hashRoot, 310 | txoStateHashes: protocolState.stateHashList, 311 | }; 312 | 313 | const leafInfo = nftOpenMinterMerkleTreeData.getMerklePath( 314 | Number(preState.nextLocalId), 315 | ); 316 | 317 | const vsize: number = await calcVsize( 318 | wallet, 319 | minter, 320 | newState, 321 | tokenState, 322 | leafInfo, 323 | preTxState, 324 | preState, 325 | minterTapScript, 326 | nftTapScript, 327 | minterInputIndex, 328 | revealTx, 329 | commitTx, 330 | changeScript, 331 | nftCommitScript, 332 | backtraceInfo, 333 | cblockMinter, 334 | cblockNft, 335 | ); 336 | 337 | const changeAmount = 338 | revealTx.inputAmount - 339 | vsize * feeRate - 340 | Postage.MINTER_POSTAGE * 341 | (newNextLocalId < collectionInfo.metadata.max ? 1 : 0) - 342 | Postage.TOKEN_POSTAGE; 343 | 344 | if (changeAmount < 546) { 345 | const message = 'Insufficient satoshis balance!'; 346 | return new Error(message); 347 | } 348 | 349 | // update change amount 350 | const changeOutputIndex = revealTx.outputs.length - 1; 351 | revealTx.outputs[changeOutputIndex].satoshis = changeAmount; 352 | 353 | const txCtxs = getTxCtxMulti( 354 | revealTx, 355 | [minterInputIndex, minterInputIndex + 1], 356 | [Buffer.from(minterTapScript, 'hex'), Buffer.from(nftTapScript, 'hex')], 357 | ); 358 | 359 | const changeInfo: ChangeInfo = { 360 | script: toByteString(changeScript.toHex()), 361 | satoshis: int2ByteString(BigInt(changeAmount), 8n), 362 | }; 363 | 364 | const { shPreimage, prevoutsCtx, spentScripts, sighash } = 365 | txCtxs[minterInputIndex]; 366 | 367 | const sig = btc.crypto.Schnorr.sign( 368 | wallet.getTokenPrivateKey(), 369 | sighash.hash, 370 | ); 371 | 372 | const minterCall = await minter.methods.mint( 373 | newState.stateHashList, 374 | tokenState, 375 | leafInfo.neighbor, 376 | leafInfo.neighborType, 377 | wallet.getPubKeyPrefix(), 378 | wallet.getXOnlyPublicKey(), 379 | () => sig.toString('hex'), 380 | int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n), 381 | int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n), 382 | preState, 383 | preTxState, 384 | backtraceInfo, 385 | shPreimage, 386 | prevoutsCtx, 387 | spentScripts, 388 | changeInfo, 389 | { 390 | fromUTXO: getDummyUTXO(), 391 | verify: false, 392 | exec: false, 393 | } as MethodCallOptions, 394 | ); 395 | const witnesses = [ 396 | ...callToBufferList(minterCall), 397 | minter.lockingScript.toBuffer(), 398 | Buffer.from(cblockMinter, 'hex'), 399 | ]; 400 | revealTx.inputs[minterInputIndex].witnesses = witnesses; 401 | 402 | if (config.getVerify()) { 403 | const res = verifyContract( 404 | minterUtxo, 405 | revealTx, 406 | minterInputIndex, 407 | witnesses, 408 | ); 409 | if (typeof res === 'string') { 410 | console.log('unlocking minter failed:', res); 411 | return new Error('unlocking minter failed'); 412 | } 413 | } 414 | if ( 415 | !unlockNFT( 416 | wallet, 417 | commitTx, 418 | revealTx, 419 | nftCommitScript, 420 | cblockNft, 421 | txCtxs[minterInputIndex + 1], 422 | config.getVerify(), 423 | ) 424 | ) { 425 | return new Error('unlock NFT commit UTXO failed'); 426 | } 427 | 428 | wallet.signTx(revealTx); 429 | 430 | let res = await broadcast(config, wallet, commitTx.uncheckedSerialize()); 431 | 432 | if (res instanceof Error) { 433 | logerror('broadcast commit NFT tx failed!', res); 434 | return res; 435 | } 436 | 437 | console.log( 438 | `Commiting ${collectionInfo.metadata.symbol}:${minterContract.state.data.nextLocalId} NFT in txid: ${res}`, 439 | ); 440 | 441 | spendService.updateSpends(commitTx); 442 | 443 | res = await broadcast(config, wallet, revealTx.uncheckedSerialize()); 444 | 445 | if (res instanceof Error) { 446 | logerror('broadcast reveal NFT tx failed!', res); 447 | return res; 448 | } 449 | 450 | spendService.updateSpends(revealTx); 451 | 452 | return revealTx.id; 453 | } 454 | 455 | export function updateMerkleTree( 456 | collectionMerkleTree: NftOpenMinterMerkleTreeData, 457 | max: int32, 458 | nextLocalId: int32, 459 | ) { 460 | for (let i = 0n; i < max; i++) { 461 | if (i < nextLocalId) { 462 | const oldLeaf = collectionMerkleTree.getLeaf(Number(i)); 463 | const newLeaf: NftMerkleLeaf = { 464 | commitScript: oldLeaf.commitScript, 465 | localId: oldLeaf.localId, 466 | isMined: true, 467 | }; 468 | collectionMerkleTree.updateLeaf(newLeaf, Number(i)); 469 | } 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /src/commands/mint/nft.parallel-closed-mint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | toByteString, 3 | UTXO, 4 | MethodCallOptions, 5 | int2ByteString, 6 | SmartContract, 7 | } from 'scrypt-ts'; 8 | import { 9 | getRawTransaction, 10 | getDummySigner, 11 | getDummyUTXO, 12 | callToBufferList, 13 | broadcast, 14 | resetTx, 15 | toStateScript, 16 | outpoint2ByteString, 17 | Postage, 18 | toP2tr, 19 | logerror, 20 | btc, 21 | verifyContract, 22 | CollectionInfo, 23 | getNftParallelClosedMinterContractP2TR, 24 | script2P2TR, 25 | NFTParallelClosedMinterContract, 26 | } from 'src/common'; 27 | 28 | import { 29 | getBackTraceInfo, 30 | ProtocolState, 31 | PreTxStatesInfo, 32 | ChangeInfo, 33 | NftParallelClosedMinterProto, 34 | NftParallelClosedMinterState, 35 | CAT721State, 36 | CAT721Proto, 37 | getTxCtxMulti, 38 | NftParallelClosedMinter, 39 | } from '@cat-protocol/cat-smartcontracts'; 40 | import { ConfigService, SpendService, WalletService } from 'src/providers'; 41 | import { createNft, unlockNFT } from './nft'; 42 | 43 | const calcVsize = async ( 44 | wallet: WalletService, 45 | minter: SmartContract, 46 | newState: ProtocolState, 47 | tokenMint: CAT721State, 48 | preTxState: PreTxStatesInfo, 49 | preState: NftParallelClosedMinterState, 50 | minterTapScript: string, 51 | nftTapScript: string, 52 | inputIndex: number, 53 | revealTx: btc.Transaction, 54 | commitTx: btc.Transaction, 55 | changeScript: btc.Script, 56 | nftCommitScript: btc.Script, 57 | backtraceInfo: any, 58 | cblockMinter: string, 59 | cblocknft: string, 60 | ) => { 61 | const txCtxs = getTxCtxMulti( 62 | revealTx, 63 | [0, 1], 64 | [Buffer.from(minterTapScript, 'hex'), Buffer.from(nftTapScript, 'hex')], 65 | ); 66 | unlockNFT(wallet, commitTx, revealTx, nftCommitScript, cblocknft, txCtxs[1]); 67 | 68 | const { sighash, shPreimage, prevoutsCtx, spentScripts } = txCtxs[0]; 69 | 70 | const changeInfo: ChangeInfo = { 71 | script: toByteString(changeScript.toHex()), 72 | satoshis: int2ByteString(BigInt(0n), 8n), 73 | }; 74 | const sig = btc.crypto.Schnorr.sign( 75 | wallet.getTokenPrivateKey(), 76 | sighash.hash, 77 | ); 78 | const minterCall = await minter.methods.mint( 79 | newState.stateHashList, 80 | tokenMint, 81 | wallet.getPubKeyPrefix(), 82 | wallet.getXOnlyPublicKey(), 83 | () => sig.toString('hex'), 84 | int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n), 85 | int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n), 86 | preState, 87 | preTxState, 88 | backtraceInfo, 89 | shPreimage, 90 | prevoutsCtx, 91 | spentScripts, 92 | changeInfo, 93 | { 94 | fromUTXO: getDummyUTXO(), 95 | verify: false, 96 | exec: false, 97 | } as MethodCallOptions, 98 | ); 99 | const witnesses = [ 100 | ...callToBufferList(minterCall), 101 | minter.lockingScript.toBuffer(), 102 | Buffer.from(cblockMinter, 'hex'), 103 | ]; 104 | revealTx.inputs[inputIndex].witnesses = witnesses; 105 | wallet.signTx(revealTx); 106 | const vsize = revealTx.vsize; 107 | resetTx(revealTx); 108 | return vsize; 109 | }; 110 | 111 | export async function parallelClosedMint( 112 | config: ConfigService, 113 | wallet: WalletService, 114 | spendService: SpendService, 115 | feeRate: number, 116 | feeUtxos: UTXO[], 117 | collectionInfo: CollectionInfo, 118 | minterContract: NFTParallelClosedMinterContract, 119 | contentType: string, 120 | contentBody: string, 121 | nftmetadata: object, 122 | owner: string = '', 123 | ): Promise { 124 | const { 125 | utxo: minterUtxo, 126 | state: { protocolState, data: preState }, 127 | } = minterContract; 128 | 129 | const address = wallet.getAddress(); 130 | 131 | owner = owner || wallet.getTokenAddress(); 132 | 133 | const tokenP2TR = toP2tr(collectionInfo.collectionAddr); 134 | 135 | const { commitTx, feeUTXO, nftCommitScript } = createNft( 136 | wallet, 137 | feeRate, 138 | feeUtxos, 139 | address, 140 | contentType, 141 | contentBody, 142 | nftmetadata, 143 | ); 144 | 145 | const genesisId = outpoint2ByteString(collectionInfo.collectionId); 146 | 147 | const newState = ProtocolState.getEmptyState(); 148 | 149 | const max = collectionInfo.metadata.max; 150 | const newNextLocalId1 = preState.nextLocalId * 2n + 1n; 151 | const newNextLocalId2 = preState.nextLocalId * 2n + 2n; 152 | 153 | const minterStates: NftParallelClosedMinterState[] = []; 154 | if (newNextLocalId1 < max) { 155 | const minterState = NftParallelClosedMinterProto.create( 156 | tokenP2TR, 157 | newNextLocalId1, 158 | ); 159 | minterStates.push(minterState); 160 | newState.updateDataList( 161 | 0, 162 | NftParallelClosedMinterProto.toByteString(minterState), 163 | ); 164 | } 165 | 166 | if (newNextLocalId2 < max) { 167 | const minterState = NftParallelClosedMinterProto.create( 168 | tokenP2TR, 169 | newNextLocalId2, 170 | ); 171 | minterStates.push(minterState); 172 | newState.updateDataList( 173 | 1, 174 | NftParallelClosedMinterProto.toByteString(minterState), 175 | ); 176 | } 177 | 178 | const tokenState = CAT721Proto.create(owner, preState.nextLocalId); 179 | newState.updateDataList( 180 | minterStates.length, 181 | CAT721Proto.toByteString(tokenState), 182 | ); 183 | 184 | const issuerAddress = wallet.getTokenAddress(); 185 | const { 186 | tapScript: minterTapScript, 187 | cblock: cblockMinter, 188 | contract: minter, 189 | } = getNftParallelClosedMinterContractP2TR( 190 | issuerAddress, 191 | genesisId, 192 | collectionInfo.metadata.max, 193 | ); 194 | 195 | const { tapScript: nftTapScript, cblock: cblockNft } = 196 | script2P2TR(nftCommitScript); 197 | 198 | const changeScript = btc.Script.fromAddress(address); 199 | 200 | const nftUTXO: UTXO = { 201 | txId: commitTx.id, 202 | outputIndex: 0, 203 | satoshis: commitTx.outputs[0].satoshis, 204 | script: commitTx.outputs[0].script.toHex(), 205 | }; 206 | 207 | const revealTx = new btc.Transaction() 208 | .from([minterUtxo, nftUTXO, feeUTXO]) 209 | .addOutput( 210 | new btc.Transaction.Output({ 211 | satoshis: 0, 212 | script: toStateScript(newState), 213 | }), 214 | ); 215 | 216 | if (minterStates.length > 0) { 217 | for (let i = 0; i < minterStates.length; i++) { 218 | revealTx.addOutput( 219 | new btc.Transaction.Output({ 220 | script: new btc.Script(minterUtxo.script), 221 | satoshis: Postage.MINTER_POSTAGE, 222 | }), 223 | ); 224 | } 225 | } 226 | 227 | revealTx 228 | .addOutput( 229 | new btc.Transaction.Output({ 230 | satoshis: Postage.TOKEN_POSTAGE, 231 | script: tokenP2TR, 232 | }), 233 | ) 234 | .addOutput( 235 | new btc.Transaction.Output({ 236 | satoshis: 0, 237 | script: changeScript, 238 | }), 239 | ) 240 | .feePerByte(feeRate); 241 | 242 | const minterInputIndex = 0; 243 | 244 | const prevTxHex = await getRawTransaction(config, minterUtxo.txId); 245 | if (prevTxHex instanceof Error) { 246 | logerror(`get raw transaction ${minterUtxo.txId} failed!`, prevTxHex); 247 | return prevTxHex; 248 | } 249 | 250 | const prevTx = new btc.Transaction(prevTxHex); 251 | 252 | const prevPrevTxId = prevTx.inputs[minterInputIndex].prevTxId.toString('hex'); 253 | const prevPrevTxHex = await getRawTransaction(config, prevPrevTxId); 254 | if (prevPrevTxHex instanceof Error) { 255 | logerror(`get raw transaction ${prevPrevTxId} failed!`, prevPrevTxHex); 256 | return prevPrevTxHex; 257 | } 258 | 259 | const prevPrevTx = new btc.Transaction(prevPrevTxHex); 260 | 261 | const backtraceInfo = getBackTraceInfo(prevTx, prevPrevTx, minterInputIndex); 262 | 263 | await minter.connect(getDummySigner()); 264 | 265 | const preTxState: PreTxStatesInfo = { 266 | statesHashRoot: protocolState.hashRoot, 267 | txoStateHashes: protocolState.stateHashList, 268 | }; 269 | 270 | const vsize: number = await calcVsize( 271 | wallet, 272 | minter, 273 | newState, 274 | tokenState, 275 | preTxState, 276 | preState, 277 | minterTapScript, 278 | nftTapScript, 279 | minterInputIndex, 280 | revealTx, 281 | commitTx, 282 | changeScript, 283 | nftCommitScript, 284 | backtraceInfo, 285 | cblockMinter, 286 | cblockNft, 287 | ); 288 | 289 | const changeAmount = 290 | revealTx.inputAmount - 291 | vsize * feeRate - 292 | Postage.MINTER_POSTAGE * minterStates.length - 293 | Postage.TOKEN_POSTAGE; 294 | 295 | if (changeAmount < 546) { 296 | const message = 'Insufficient satoshis balance!'; 297 | return new Error(message); 298 | } 299 | 300 | // update change amount 301 | const changeOutputIndex = revealTx.outputs.length - 1; 302 | revealTx.outputs[changeOutputIndex].satoshis = changeAmount; 303 | 304 | const txCtxs = getTxCtxMulti( 305 | revealTx, 306 | [minterInputIndex, minterInputIndex + 1], 307 | [Buffer.from(minterTapScript, 'hex'), Buffer.from(nftTapScript, 'hex')], 308 | ); 309 | 310 | const changeInfo: ChangeInfo = { 311 | script: toByteString(changeScript.toHex()), 312 | satoshis: int2ByteString(BigInt(changeAmount), 8n), 313 | }; 314 | 315 | const { shPreimage, prevoutsCtx, spentScripts, sighash } = 316 | txCtxs[minterInputIndex]; 317 | 318 | const sig = btc.crypto.Schnorr.sign( 319 | wallet.getTokenPrivateKey(), 320 | sighash.hash, 321 | ); 322 | 323 | const minterCall = await minter.methods.mint( 324 | newState.stateHashList, 325 | tokenState, 326 | wallet.getPubKeyPrefix(), 327 | wallet.getXOnlyPublicKey(), 328 | () => sig.toString('hex'), 329 | int2ByteString(BigInt(Postage.MINTER_POSTAGE), 8n), 330 | int2ByteString(BigInt(Postage.TOKEN_POSTAGE), 8n), 331 | preState, 332 | preTxState, 333 | backtraceInfo, 334 | shPreimage, 335 | prevoutsCtx, 336 | spentScripts, 337 | changeInfo, 338 | { 339 | fromUTXO: getDummyUTXO(), 340 | verify: false, 341 | exec: false, 342 | } as MethodCallOptions, 343 | ); 344 | const witnesses = [ 345 | ...callToBufferList(minterCall), 346 | minter.lockingScript.toBuffer(), 347 | Buffer.from(cblockMinter, 'hex'), 348 | ]; 349 | revealTx.inputs[minterInputIndex].witnesses = witnesses; 350 | 351 | if (config.getVerify()) { 352 | const res = verifyContract( 353 | minterUtxo, 354 | revealTx, 355 | minterInputIndex, 356 | witnesses, 357 | ); 358 | if (typeof res === 'string') { 359 | console.log('unlocking minter failed:', res); 360 | return new Error('unlocking minter failed'); 361 | } 362 | } 363 | if ( 364 | !unlockNFT( 365 | wallet, 366 | commitTx, 367 | revealTx, 368 | nftCommitScript, 369 | cblockNft, 370 | txCtxs[minterInputIndex + 1], 371 | config.getVerify(), 372 | ) 373 | ) { 374 | return new Error('unlock NFT commit UTXO failed'); 375 | } 376 | 377 | wallet.signTx(revealTx); 378 | 379 | let res = await broadcast(config, wallet, commitTx.uncheckedSerialize()); 380 | 381 | if (res instanceof Error) { 382 | logerror('broadcast commit NFT tx failed!', res); 383 | return res; 384 | } 385 | 386 | console.log( 387 | `Commiting ${collectionInfo.metadata.symbol}:${minterContract.state.data.nextLocalId} NFT in txid: ${res}`, 388 | ); 389 | spendService.updateSpends(commitTx); 390 | 391 | res = await broadcast(config, wallet, revealTx.uncheckedSerialize()); 392 | 393 | if (res instanceof Error) { 394 | logerror('broadcast reveal NFT tx failed!', res); 395 | return res; 396 | } 397 | 398 | spendService.updateSpends(revealTx); 399 | 400 | return revealTx.id; 401 | } 402 | -------------------------------------------------------------------------------- /src/commands/mint/nft.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import { WalletService } from 'src/providers'; 3 | import { btc, Postage, script2P2TR } from 'src/common'; 4 | import { getCatNFTCommitScript } from '@cat-protocol/cat-smartcontracts'; 5 | 6 | export function unlockNFT( 7 | wallet: WalletService, 8 | commitTx: btc.Transaction, 9 | revealTx: btc.Transaction, 10 | lockingScript: btc.Script, 11 | cblock: string, 12 | txCtx: any, 13 | verify: boolean = false, 14 | ) { 15 | const nftInputIndex = 1; 16 | const witnesses: Buffer[] = []; 17 | 18 | const { sighash } = txCtx; 19 | 20 | const sig = btc.crypto.Schnorr.sign( 21 | wallet.getTaprootPrivateKey(), 22 | sighash.hash, 23 | ); 24 | 25 | witnesses.push(sig); 26 | witnesses.push(lockingScript); 27 | witnesses.push(Buffer.from(cblock, 'hex')); 28 | 29 | if (verify) { 30 | const interpreter = new btc.Script.Interpreter(); 31 | const flags = 32 | btc.Script.Interpreter.SCRIPT_VERIFY_WITNESS | 33 | btc.Script.Interpreter.SCRIPT_VERIFY_TAPROOT; 34 | 35 | const res = interpreter.verify( 36 | new btc.Script(''), 37 | commitTx.outputs[0].script, 38 | revealTx, 39 | nftInputIndex, 40 | flags, 41 | witnesses, 42 | commitTx.outputs[0].satoshis, 43 | ); 44 | 45 | if (!res) { 46 | console.error('reveal nft faild!', interpreter.errstr); 47 | return false; 48 | } 49 | } 50 | 51 | revealTx.inputs[nftInputIndex].witnesses = witnesses; 52 | return true; 53 | } 54 | 55 | export function createNft( 56 | wallet: WalletService, 57 | feeRate: number, 58 | feeUtxos: UTXO[], 59 | changeAddress: btc.Address, 60 | contentType: string, 61 | contentBody: string, 62 | nftmetadata: object, 63 | ): { 64 | commitTx: btc.Transaction; 65 | feeUTXO: UTXO; 66 | nftCommitScript: btc.Script; 67 | } { 68 | const pubkeyX = wallet.getXOnlyPublicKey(); 69 | const nftCommitScript = getCatNFTCommitScript(pubkeyX, nftmetadata, { 70 | type: contentType, 71 | body: contentBody, 72 | }); 73 | 74 | const lockingScript = Buffer.from(nftCommitScript, 'hex'); 75 | const { p2tr: p2tr } = script2P2TR(lockingScript); 76 | 77 | const commitTx = new btc.Transaction() 78 | .from(feeUtxos) 79 | .addOutput( 80 | new btc.Transaction.Output({ 81 | satoshis: Postage.NFT_POSTAGE, 82 | script: p2tr, 83 | }), 84 | ) 85 | .feePerByte(feeRate) 86 | .change(changeAddress); 87 | 88 | if (commitTx.getChangeOutput() === null) { 89 | console.error('Insufficient satoshis balance!'); 90 | return null; 91 | } 92 | commitTx.getChangeOutput().satoshis -= 1; 93 | wallet.signTx(commitTx); 94 | return { 95 | commitTx, 96 | nftCommitScript: lockingScript, 97 | feeUTXO: { 98 | txId: commitTx.id, 99 | outputIndex: 1, 100 | satoshis: commitTx.outputs[1].satoshis, 101 | script: commitTx.outputs[1].script.toHex(), 102 | }, 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/commands/mint/split.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import { ConfigService, WalletService } from 'src/providers'; 3 | import { btc, broadcast, log } from 'src/common'; 4 | export async function feeSplitTx( 5 | configService: ConfigService, 6 | walletService: WalletService, 7 | feeUtxos: UTXO[], 8 | feeRate: number, 9 | count: number, 10 | ) { 11 | const address = walletService.getAddress(); 12 | const splitFeeTx = new btc.Transaction(); 13 | 14 | splitFeeTx.from(feeUtxos); 15 | 16 | function calcVsize(walletService: WalletService): number { 17 | const _splitFeeTx = new btc.Transaction(); 18 | 19 | _splitFeeTx.from(feeUtxos); 20 | 21 | for (let i = 0; i < count; i++) { 22 | _splitFeeTx.addOutput( 23 | new btc.Transaction.Output({ 24 | satoshis: 0, 25 | script: btc.Script.fromAddress(address), 26 | }), 27 | ); 28 | } 29 | _splitFeeTx.feePerByte(feeRate); 30 | walletService.signTx(_splitFeeTx); 31 | return _splitFeeTx.vsize; 32 | } 33 | 34 | const vSize = calcVsize(walletService); 35 | 36 | const fee = vSize * feeRate; 37 | 38 | const satoshisPerOutput = Math.floor((splitFeeTx.inputAmount - fee) / count); 39 | 40 | for (let i = 0; i < count; i++) { 41 | splitFeeTx.addOutput( 42 | new btc.Transaction.Output({ 43 | satoshis: satoshisPerOutput, 44 | script: btc.Script.fromAddress(address), 45 | }), 46 | ); 47 | } 48 | 49 | walletService.signTx(splitFeeTx); 50 | 51 | //const txId = splitFeeTx.id; 52 | const txId = await broadcast( 53 | configService, 54 | walletService, 55 | splitFeeTx.uncheckedSerialize(), 56 | ); 57 | if (txId instanceof Error) { 58 | throw txId; 59 | } else { 60 | log(`Spliting fee in txid: ${txId}`); 61 | } 62 | 63 | const newfeeUtxos: UTXO[] = []; 64 | 65 | for (let i = 0; i < count; i++) { 66 | newfeeUtxos.push({ 67 | txId, 68 | outputIndex: i, 69 | script: splitFeeTx.outputs[i].script.toHex(), 70 | satoshis: splitFeeTx.outputs[i].satoshis, 71 | }); 72 | } 73 | return { txId: splitFeeTx.id, newfeeUtxos }; 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/send/pick.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import { NFTContract } from 'src/common'; 3 | 4 | export function pickLargeFeeUtxo(feeUtxos: Array): UTXO { 5 | let max = feeUtxos[0]; 6 | 7 | for (const utxo of feeUtxos) { 8 | if (utxo.satoshis > max.satoshis) { 9 | max = utxo; 10 | } 11 | } 12 | return max; 13 | } 14 | 15 | export function pickBylocalId( 16 | contracts: Array, 17 | localId: bigint, 18 | ): NFTContract | undefined { 19 | return contracts.find((contract) => contract.state.data.localId === localId); 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/send/send.command.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from 'nest-commander'; 2 | import { 3 | logerror, 4 | btc, 5 | CollectionInfo, 6 | getUtxos, 7 | broadcast, 8 | getNft, 9 | toTokenAddress, 10 | } from 'src/common'; 11 | import { ConfigService, SpendService, WalletService } from 'src/providers'; 12 | import { Inject } from '@nestjs/common'; 13 | import { 14 | BoardcastCommand, 15 | BoardcastCommandOptions, 16 | } from '../boardcast.command'; 17 | import { findCollectionInfoById } from 'src/collection'; 18 | import { sendNfts } from './nft'; 19 | import { pickLargeFeeUtxo } from './pick'; 20 | 21 | interface SendCommandOptions extends BoardcastCommandOptions { 22 | id: string; 23 | localId: bigint; 24 | address: string; 25 | amount: bigint; 26 | config?: string; 27 | } 28 | 29 | @Command({ 30 | name: 'send', 31 | description: 'Send tokens', 32 | }) 33 | export class SendCommand extends BoardcastCommand { 34 | constructor( 35 | @Inject() private readonly spendService: SpendService, 36 | @Inject() protected readonly walletService: WalletService, 37 | @Inject() protected readonly configService: ConfigService, 38 | ) { 39 | super(spendService, walletService, configService); 40 | } 41 | async cat_cli_run( 42 | inputs: string[], 43 | options?: SendCommandOptions, 44 | ): Promise { 45 | if (!options.id) { 46 | logerror('expect a nft collectionId option', new Error()); 47 | return; 48 | } 49 | 50 | if (typeof options.localId === 'undefined') { 51 | logerror('expect a nft localId option', new Error()); 52 | return; 53 | } 54 | 55 | try { 56 | const address = this.walletService.getAddress(); 57 | const collectionInfo = await findCollectionInfoById( 58 | this.configService, 59 | options.id, 60 | ); 61 | 62 | if (!collectionInfo) { 63 | throw new Error( 64 | `No collection info found for collectionId: ${options.id}`, 65 | ); 66 | } 67 | 68 | let receiver: btc.Address; 69 | try { 70 | receiver = btc.Address.fromString(inputs[0]); 71 | 72 | if ( 73 | receiver.type !== 'taproot' && 74 | receiver.type !== 'witnesspubkeyhash' 75 | ) { 76 | console.error(`Invalid address type: ${receiver.type}`); 77 | return; 78 | } 79 | } catch (error) { 80 | console.error(`Invalid receiver address: "${inputs[0]}" `); 81 | return; 82 | } 83 | 84 | await this.send(collectionInfo, receiver, address, options); 85 | return; 86 | } catch (error) { 87 | logerror(`send token failed!`, error); 88 | } 89 | } 90 | 91 | async send( 92 | collectionInfo: CollectionInfo, 93 | receiver: btc.Address, 94 | address: btc.Address, 95 | options: SendCommandOptions, 96 | ) { 97 | const feeRate = await this.getFeeRate(); 98 | 99 | let feeUtxos = await getUtxos( 100 | this.configService, 101 | this.walletService, 102 | address, 103 | ); 104 | 105 | feeUtxos = feeUtxos.filter((utxo) => { 106 | return this.spendService.isUnspent(utxo); 107 | }); 108 | 109 | if (feeUtxos.length === 0) { 110 | console.warn('Insufficient satoshis balance!'); 111 | return; 112 | } 113 | 114 | const nft = await getNft( 115 | this.configService, 116 | collectionInfo, 117 | options.localId, 118 | ); 119 | 120 | if (!nft) { 121 | console.error('getNft return null!'); 122 | return; 123 | } 124 | 125 | if (nft.state.data.ownerAddr !== toTokenAddress(address)) { 126 | console.log( 127 | `${collectionInfo.collectionId}:${options.localId} nft is not owned by your address ${address}`, 128 | ); 129 | return; 130 | } 131 | 132 | const feeUtxo = pickLargeFeeUtxo(feeUtxos); 133 | if (!nft) { 134 | console.error(`No nft localId = ${options.localId} found!`); 135 | return; 136 | } 137 | 138 | const cachedTxs: Map = new Map(); 139 | const result = await sendNfts( 140 | this.configService, 141 | this.walletService, 142 | feeUtxo, 143 | feeRate, 144 | collectionInfo, 145 | [nft], 146 | address, 147 | receiver, 148 | cachedTxs, 149 | ); 150 | 151 | if (result) { 152 | const commitTxId = await broadcast( 153 | this.configService, 154 | this.walletService, 155 | result.commitTx.uncheckedSerialize(), 156 | ); 157 | 158 | if (commitTxId instanceof Error) { 159 | throw commitTxId; 160 | } 161 | 162 | this.spendService.updateSpends(result.commitTx); 163 | 164 | const revealTxId = await broadcast( 165 | this.configService, 166 | this.walletService, 167 | result.revealTx.uncheckedSerialize(), 168 | ); 169 | 170 | if (revealTxId instanceof Error) { 171 | throw revealTxId; 172 | } 173 | 174 | this.spendService.updateSpends(result.revealTx); 175 | 176 | console.log( 177 | `Sending ${collectionInfo.collectionId}:${options.localId} nft to ${receiver} \nin txid: ${result.revealTx.id}`, 178 | ); 179 | } 180 | } 181 | 182 | @Option({ 183 | flags: '-i, --id [collectionId]', 184 | description: 'ID of the nft collection', 185 | }) 186 | parseId(val: string): string { 187 | return val; 188 | } 189 | 190 | @Option({ 191 | flags: '-l, --localId [localId]', 192 | description: 'localId of the nft', 193 | }) 194 | parseLocalId(val: string): bigint { 195 | try { 196 | return BigInt(val); 197 | } catch (error) { 198 | throw new Error(`Invalid localId: ${val}`); 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/commands/version.command.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandRunner } from 'nest-commander'; 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const { version } = require('../../package.json'); 4 | 5 | @Command({ 6 | name: 'version', 7 | description: 'Output the version number', 8 | }) 9 | export class VersionCommand extends CommandRunner { 10 | constructor() { 11 | super(); 12 | } 13 | 14 | async run(): Promise { 15 | console.log(`v${version}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/commands/wallet/address.command.ts: -------------------------------------------------------------------------------- 1 | import { SubCommand } from 'nest-commander'; 2 | import { log, logerror } from 'src/common'; 3 | import { BaseCommand, BaseCommandOptions } from '../base.command'; 4 | import { ConfigService, WalletService } from 'src/providers'; 5 | import { Inject } from '@nestjs/common'; 6 | 7 | interface AddressCommandOptions extends BaseCommandOptions {} 8 | 9 | @SubCommand({ 10 | name: 'address', 11 | description: 'Show address', 12 | }) 13 | export class AddressCommand extends BaseCommand { 14 | constructor( 15 | @Inject() protected readonly walletService: WalletService, 16 | @Inject() protected readonly configService: ConfigService, 17 | ) { 18 | super(walletService, configService); 19 | } 20 | 21 | async cat_cli_run( 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | inputs: string[], 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | options?: AddressCommandOptions, 26 | ): Promise { 27 | try { 28 | const address = this.walletService.getAddress(); 29 | 30 | log(`Your address is ${address}`); 31 | } catch (error) { 32 | logerror('Get address failed!', error); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/wallet/balance.command.ts: -------------------------------------------------------------------------------- 1 | import { Option, SubCommand } from 'nest-commander'; 2 | import { 3 | logerror, 4 | getTrackerStatus, 5 | getNfts, 6 | btc, 7 | getCollectionsByOwner, 8 | } from 'src/common'; 9 | import { BaseCommand, BaseCommandOptions } from '../base.command'; 10 | import { ConfigService, WalletService } from 'src/providers'; 11 | import { Inject } from '@nestjs/common'; 12 | import { findCollectionInfoById } from 'src/collection'; 13 | import { table } from './table'; 14 | import Decimal from 'decimal.js'; 15 | 16 | interface BalanceCommandOptions extends BaseCommandOptions { 17 | id: string; 18 | } 19 | 20 | @SubCommand({ 21 | name: 'balances', 22 | description: 'Get balances of nft', 23 | }) 24 | export class BalanceCommand extends BaseCommand { 25 | constructor( 26 | @Inject() protected readonly walletService: WalletService, 27 | @Inject() protected readonly configService: ConfigService, 28 | ) { 29 | super(walletService, configService); 30 | } 31 | 32 | async checkTrackerStatus() { 33 | const status = await getTrackerStatus(this.configService); 34 | if (status instanceof Error) { 35 | throw new Error('tracker status is abnormal'); 36 | } 37 | 38 | const { trackerBlockHeight, latestBlockHeight } = status; 39 | 40 | if (trackerBlockHeight < latestBlockHeight) { 41 | console.warn('tracker is behind latest blockchain height'); 42 | console.warn( 43 | `processing ${trackerBlockHeight}/${latestBlockHeight}: ${new Decimal(trackerBlockHeight).div(latestBlockHeight).mul(100).toFixed(0)}%`, 44 | ); 45 | } 46 | } 47 | async cat_cli_run( 48 | passedParams: string[], 49 | options?: BalanceCommandOptions, 50 | ): Promise { 51 | const address = this.walletService.getAddress(); 52 | 53 | if (!options.id) { 54 | this.showAllColloction(address); 55 | return; 56 | } 57 | 58 | this.showOneCollection(options.id, address); 59 | } 60 | 61 | async showOneCollection(collectionId: string, address: btc.Address) { 62 | try { 63 | const collectionInfo = await findCollectionInfoById( 64 | this.configService, 65 | collectionId, 66 | ); 67 | 68 | if (!collectionInfo) { 69 | logerror( 70 | `No collection found for collectionId: ${collectionId}`, 71 | new Error(), 72 | ); 73 | await this.checkTrackerStatus(); 74 | return; 75 | } 76 | 77 | const nfts = await getNfts( 78 | this.configService, 79 | collectionInfo, 80 | address, 81 | null, 82 | ); 83 | 84 | if (nfts) { 85 | console.log( 86 | table( 87 | nfts.contracts.map((token) => { 88 | return { 89 | nft: `${collectionInfo.collectionId}:${token.state.data.localId}`, 90 | symbol: collectionInfo.metadata.symbol, 91 | //content: `${this.configService.getTracker()}/api/collections/${collectionInfo.tokenId}/localId/${token.state.data.localId}/content`, 92 | }; 93 | }), 94 | ), 95 | ); 96 | } 97 | } catch (error) { 98 | logerror('Get Balance failed!', error); 99 | } 100 | } 101 | 102 | async showAllColloction(address: btc.Address) { 103 | const collectionIds = await getCollectionsByOwner( 104 | this.configService, 105 | address.toString(), 106 | ); 107 | 108 | for (let i = 0; i < collectionIds.length; i++) { 109 | await this.showOneCollection(collectionIds[i], address); 110 | } 111 | } 112 | 113 | @Option({ 114 | flags: '-i, --id [collectionId]', 115 | description: 'ID of the collection', 116 | }) 117 | parseId(val: string): string { 118 | return val; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/commands/wallet/create.command.ts: -------------------------------------------------------------------------------- 1 | import { Option, InquirerService, SubCommand } from 'nest-commander'; 2 | import { BaseCommand, BaseCommandOptions } from '../base.command'; 3 | import { logerror, Wallet } from 'src/common'; 4 | import { ConfigService, WalletService } from 'src/providers'; 5 | import { Inject } from '@nestjs/common'; 6 | import { randomBytes } from 'crypto'; 7 | import * as bip39 from 'bip39'; 8 | 9 | interface CreateCommandOptions extends BaseCommandOptions { 10 | name: string; 11 | } 12 | 13 | @SubCommand({ 14 | name: 'create', 15 | description: 'Create a wallet.', 16 | }) 17 | export class CreateCommand extends BaseCommand { 18 | constructor( 19 | @Inject() private readonly inquirer: InquirerService, 20 | @Inject() protected readonly walletService: WalletService, 21 | @Inject() protected readonly configService: ConfigService, 22 | ) { 23 | super(walletService, configService, false); 24 | } 25 | async cat_cli_run( 26 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 27 | inputs: string[], 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | options?: CreateCommandOptions, 30 | ): Promise { 31 | try { 32 | const walletFile = this.walletService.foundWallet(); 33 | if (walletFile !== null) { 34 | logerror(`found an existing wallet: ${walletFile}`, new Error()); 35 | return; 36 | } 37 | 38 | const name = options.name 39 | ? options.name 40 | : `cat-${randomBytes(4).toString('hex')}`; 41 | 42 | const wallet: Wallet = { 43 | accountPath: "m/86'/0'/0'/0/0", 44 | name: name, 45 | mnemonic: bip39.generateMnemonic(), 46 | }; 47 | 48 | this.walletService.createWallet(wallet); 49 | 50 | console.log('Your wallet mnemonic is: ', wallet.mnemonic); 51 | 52 | console.log('exporting address to the RPC node ... '); 53 | 54 | const success = await this.walletService.importWallet(true); 55 | if (success) { 56 | console.log('successfully.'); 57 | } 58 | } catch (error) { 59 | logerror('Create wallet failed!', error); 60 | } 61 | } 62 | 63 | @Option({ 64 | flags: '-n,--name [name]', 65 | description: 'wallet name', 66 | }) 67 | parseName(val: string): string { 68 | if (!val) { 69 | logerror("wallet name can't be empty!", new Error('invalid name option')); 70 | process.exit(0); 71 | } 72 | 73 | return val; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/commands/wallet/import.command.ts: -------------------------------------------------------------------------------- 1 | import { SubCommand, Option } from 'nest-commander'; 2 | import { BaseCommand, BaseCommandOptions } from '../base.command'; 3 | import { log, logerror } from 'src/common'; 4 | import { ConfigService, WalletService } from 'src/providers'; 5 | import { Inject } from '@nestjs/common'; 6 | 7 | interface ImportCommandOptions extends BaseCommandOptions { 8 | create: boolean; 9 | } 10 | 11 | @SubCommand({ 12 | name: 'export', 13 | description: 'Export wallet to a RPC node.', 14 | }) 15 | export class ImportCommand extends BaseCommand { 16 | constructor( 17 | @Inject() protected readonly walletService: WalletService, 18 | @Inject() protected readonly configService: ConfigService, 19 | ) { 20 | super(walletService, configService); 21 | } 22 | async cat_cli_run( 23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 24 | inputs: string[], 25 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 26 | options?: ImportCommandOptions, 27 | ): Promise { 28 | try { 29 | if (!this.configService.useRpc()) { 30 | log('Please config your rpc first!'); 31 | return; 32 | } 33 | console.log('exporting address to the RPC node ... '); 34 | 35 | const success = await this.walletService.importWallet(options.create); 36 | 37 | if (success) { 38 | console.log('successfully.'); 39 | } 40 | } catch (error) { 41 | logerror('exporting address to the RPC node failed!', error); 42 | } 43 | } 44 | 45 | @Option({ 46 | flags: '--create [create]', 47 | defaultValue: false, 48 | description: 'create watch only wallet before import address', 49 | }) 50 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 51 | parseCreate(val: string): boolean { 52 | return true; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/wallet/table.ts: -------------------------------------------------------------------------------- 1 | import { Console } from 'node:console'; 2 | import { Transform } from 'node:stream'; 3 | 4 | const ts = new Transform({ transform: (chunk, _, cb) => cb(null, chunk) }); 5 | const logger = new Console({ stdout: ts, stderr: ts, colorMode: false }); 6 | const handler = { 7 | get(_, prop) { 8 | return new Proxy(logger[prop], handler); 9 | }, 10 | apply(target, _, args) { 11 | target.apply(logger, args); 12 | return (ts.read() || '').toString(); 13 | }, 14 | }; 15 | 16 | const dumper = new Proxy(logger, handler); 17 | 18 | export type TableParameters = Parameters<(typeof dumper)['table']>; 19 | 20 | export function table(...parameters: TableParameters): string { 21 | const original = dumper.table(...parameters); 22 | 23 | // Tables should all start with roughly: 24 | // ┌─────────┬────── 25 | // │ (index) │ 26 | // ├─────────┼ 27 | const columnWidth = original.indexOf('┬─'); 28 | 29 | const trimmed = original 30 | .split('\n') 31 | .map((line) => line.slice(columnWidth)) 32 | .join('\n'); 33 | 34 | return '┌' + trimmed.slice(1); 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/wallet/wallet.command.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandRunner } from 'nest-commander'; 2 | import { AddressCommand } from './address.command'; 3 | import { CreateCommand } from './create.command'; 4 | import { ImportCommand } from './import.command'; 5 | import { logerror } from 'src/common'; 6 | import { BalanceCommand } from './balance.command'; 7 | interface WalletCommandOptions {} 8 | 9 | @Command({ 10 | name: 'wallet', 11 | arguments: '', 12 | description: 'Wallet commands', 13 | subCommands: [AddressCommand, CreateCommand, ImportCommand, BalanceCommand], 14 | }) 15 | export class WalletCommand extends CommandRunner { 16 | async run( 17 | passedParams: string[], 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | options?: WalletCommandOptions, 20 | ): Promise { 21 | if (passedParams[0]) { 22 | logerror(`Unknow subCommand \'${passedParams[0]}\'`, new Error()); 23 | return; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/common/apis-rpc.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import { Decimal } from 'decimal.js'; 3 | import * as descriptors from '@bitcoinerlab/descriptors'; 4 | import { logerror } from './log'; 5 | import { ConfigService } from 'src/providers'; 6 | import fetch from 'node-fetch-cjs'; 7 | 8 | /** 9 | * only for localhost 10 | * @param txHex 11 | * @returns 12 | */ 13 | export const rpc_broadcast = async function ( 14 | config: ConfigService, 15 | walletName: string, 16 | txHex: string, 17 | ): Promise { 18 | const Authorization = `Basic ${Buffer.from( 19 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 20 | ).toString('base64')}`; 21 | 22 | return fetch(config.getRpcUrl(walletName), { 23 | method: 'POST', 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | Authorization, 27 | }, 28 | body: JSON.stringify({ 29 | jsonrpc: '2.0', 30 | id: 'cat-cli', 31 | method: 'sendrawtransaction', 32 | params: [txHex], 33 | }), 34 | }) 35 | .then((res) => { 36 | const contentType = res.headers.get('content-type'); 37 | if (contentType.includes('json')) { 38 | return res.json(); 39 | } else { 40 | throw new Error( 41 | `invalid http content type : ${contentType}, status: ${res.status}`, 42 | ); 43 | } 44 | }) 45 | .then((res: any) => { 46 | if (res.result === null) { 47 | throw new Error(JSON.stringify(res)); 48 | } 49 | return res.result; 50 | }) 51 | .catch((e) => { 52 | return e; 53 | }); 54 | }; 55 | 56 | export const rpc_getrawtransaction = async function ( 57 | config: ConfigService, 58 | txid: string, 59 | ): Promise { 60 | const Authorization = `Basic ${Buffer.from( 61 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 62 | ).toString('base64')}`; 63 | 64 | return fetch(config.getRpcUrl(null), { 65 | method: 'POST', 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | Authorization, 69 | }, 70 | body: JSON.stringify({ 71 | jsonrpc: '2.0', 72 | id: 'cat-cli', 73 | method: 'getrawtransaction', 74 | params: [txid], 75 | }), 76 | }) 77 | .then((res) => { 78 | const contentType = res.headers.get('content-type'); 79 | if (contentType.includes('json')) { 80 | return res.json(); 81 | } else { 82 | throw new Error( 83 | `invalid http content type : ${contentType}, status: ${res.status}`, 84 | ); 85 | } 86 | }) 87 | .then((res: any) => { 88 | if (res.result === null) { 89 | throw new Error(JSON.stringify(res)); 90 | } 91 | return res.result; 92 | }) 93 | .catch((e) => { 94 | logerror('broadcast_rpc failed!', e); 95 | return e; 96 | }); 97 | }; 98 | 99 | export const rpc_getconfirmations = async function ( 100 | config: ConfigService, 101 | txid: string, 102 | ): Promise< 103 | | { 104 | blockhash: string; 105 | confirmations: number; 106 | } 107 | | Error 108 | > { 109 | const Authorization = `Basic ${Buffer.from( 110 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 111 | ).toString('base64')}`; 112 | 113 | return fetch(config.getRpcUrl(null), { 114 | method: 'POST', 115 | headers: { 116 | 'Content-Type': 'application/json', 117 | Authorization, 118 | }, 119 | body: JSON.stringify({ 120 | jsonrpc: '2.0', 121 | id: 'cat-cli', 122 | method: 'getrawtransaction', 123 | params: [txid, true], 124 | }), 125 | }) 126 | .then((res) => { 127 | const contentType = res.headers.get('content-type'); 128 | if (contentType.includes('json')) { 129 | return res.json(); 130 | } else { 131 | throw new Error( 132 | `invalid http content type : ${contentType}, status: ${res.status}`, 133 | ); 134 | } 135 | }) 136 | .then((res: any) => { 137 | if (res.result === null) { 138 | throw new Error(JSON.stringify(res)); 139 | } 140 | return { 141 | confirmations: -1, 142 | blockhash: '', 143 | ...res.result, 144 | }; 145 | }) 146 | .catch((e) => { 147 | return e; 148 | }); 149 | }; 150 | 151 | export const rpc_getfeeRate = async function ( 152 | config: ConfigService, 153 | walletName: string, 154 | ): Promise { 155 | const Authorization = `Basic ${Buffer.from( 156 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 157 | ).toString('base64')}`; 158 | 159 | return fetch(config.getRpcUrl(walletName), { 160 | method: 'POST', 161 | headers: { 162 | 'Content-Type': 'application/json', 163 | Authorization, 164 | }, 165 | body: JSON.stringify({ 166 | jsonrpc: '2.0', 167 | id: 'cat-cli', 168 | method: 'estimatesmartfee', 169 | params: [1], 170 | }), 171 | }) 172 | .then((res) => { 173 | const contentType = res.headers.get('content-type'); 174 | if (contentType.includes('json')) { 175 | return res.json(); 176 | } else { 177 | throw new Error( 178 | `invalid http content type : ${contentType}, status: ${res.status}`, 179 | ); 180 | } 181 | }) 182 | .then((res: any) => { 183 | if ( 184 | res.result === null || 185 | (res.result.errors && res.result.errors.length > 0) 186 | ) { 187 | throw new Error(JSON.stringify(res)); 188 | } 189 | const feerate = new Decimal(res.result.feerate) 190 | .mul(new Decimal(100000000)) 191 | .div(new Decimal(1000)) 192 | .toNumber(); 193 | return Math.ceil(feerate); 194 | }) 195 | .catch((e: Error) => { 196 | return e; 197 | }); 198 | }; 199 | 200 | export const rpc_listunspent = async function ( 201 | config: ConfigService, 202 | walletName: string, 203 | address: string, 204 | ): Promise { 205 | const Authorization = `Basic ${Buffer.from( 206 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 207 | ).toString('base64')}`; 208 | 209 | return fetch(config.getRpcUrl(walletName), { 210 | method: 'POST', 211 | headers: { 212 | 'Content-Type': 'application/json', 213 | Authorization, 214 | }, 215 | body: JSON.stringify({ 216 | jsonrpc: '2.0', 217 | id: 'cat-cli', 218 | method: 'listunspent', 219 | params: [0, 9999999, [address]], 220 | }), 221 | }) 222 | .then((res) => { 223 | if (res.status === 200) { 224 | return res.json(); 225 | } 226 | throw new Error(res.statusText); 227 | }) 228 | .then((res: any) => { 229 | if (res.result === null) { 230 | throw new Error(JSON.stringify(res)); 231 | } 232 | 233 | const utxos: UTXO[] = res.result.map((item: any) => { 234 | return { 235 | txId: item.txid, 236 | outputIndex: item.vout, 237 | script: item.scriptPubKey, 238 | satoshis: new Decimal(item.amount) 239 | .mul(new Decimal(100000000)) 240 | .toNumber(), 241 | } as UTXO; 242 | }); 243 | 244 | return utxos; 245 | }) 246 | .catch((e: Error) => { 247 | return e; 248 | }); 249 | }; 250 | 251 | export const rpc_create_watchonly_wallet = async function ( 252 | config: ConfigService, 253 | walletName: string, 254 | ): Promise { 255 | const Authorization = `Basic ${Buffer.from( 256 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 257 | ).toString('base64')}`; 258 | 259 | return fetch(config.getRpcUrl(null), { 260 | method: 'POST', 261 | headers: { 262 | 'Content-Type': 'application/json', 263 | Authorization, 264 | }, 265 | body: JSON.stringify({ 266 | jsonrpc: '2.0', 267 | id: 'cat-cli', 268 | method: 'createwallet', 269 | params: { 270 | wallet_name: walletName, 271 | disable_private_keys: true, 272 | blank: true, 273 | passphrase: '', 274 | descriptors: true, 275 | load_on_startup: true, 276 | }, 277 | }), 278 | }) 279 | .then((res) => { 280 | if (res.status === 200) { 281 | return res.json(); 282 | } 283 | throw new Error(res.statusText); 284 | }) 285 | .then((res: any) => { 286 | if (res.result === null) { 287 | throw new Error(JSON.stringify(res)); 288 | } 289 | return null; 290 | }) 291 | .catch((e: Error) => { 292 | return e; 293 | }); 294 | }; 295 | 296 | export const rpc_importdescriptors = async function ( 297 | config: ConfigService, 298 | walletName: string, 299 | desc: string, 300 | ): Promise { 301 | const Authorization = `Basic ${Buffer.from( 302 | `${config.getRpcUser()}:${config.getRpcPassword()}`, 303 | ).toString('base64')}`; 304 | 305 | const checksum = descriptors.checksum(desc); 306 | 307 | const timestamp = Math.ceil(new Date().getTime() / 1000); 308 | return fetch(config.getRpcUrl(walletName), { 309 | method: 'POST', 310 | headers: { 311 | 'Content-Type': 'application/json', 312 | Authorization, 313 | }, 314 | body: JSON.stringify({ 315 | jsonrpc: '2.0', 316 | id: 'cat-cli', 317 | method: 'importdescriptors', 318 | params: [ 319 | [ 320 | { 321 | desc: `${desc}#${checksum}`, 322 | active: false, 323 | index: 0, 324 | internal: false, 325 | timestamp, 326 | label: '', 327 | }, 328 | ], 329 | ], 330 | }), 331 | }) 332 | .then((res) => { 333 | if (res.status === 200) { 334 | return res.json(); 335 | } 336 | throw new Error(res.statusText); 337 | }) 338 | .then((res: any) => { 339 | if ( 340 | res.result === null || 341 | res.result[0] === undefined || 342 | res.result[0].success !== true 343 | ) { 344 | throw new Error(JSON.stringify(res)); 345 | } 346 | return null; 347 | }) 348 | .catch((e: Error) => { 349 | return e; 350 | }); 351 | }; 352 | -------------------------------------------------------------------------------- /src/common/apis-tracker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NftClosedMinterState, 3 | NftOpenMinterMerkleTreeData, 4 | NftOpenMinterState, 5 | NftParallelClosedMinterState, 6 | ProtocolState, 7 | ProtocolStateList, 8 | } from '@cat-protocol/cat-smartcontracts'; 9 | import { 10 | NFTClosedMinterContract, 11 | NFTContract, 12 | NFTOpenMinterContract, 13 | NFTParallelClosedMinterContract, 14 | } from './contact'; 15 | import { CollectionInfo } from './metadata'; 16 | import { 17 | isNFTClosedMinter, 18 | isNFTOpenMinter, 19 | isNFTParallelClosedMinter, 20 | } from './minterFinder'; 21 | import { getNFTContractP2TR, p2tr2Address, script2P2TR, toP2tr } from './utils'; 22 | import { logerror } from './log'; 23 | import { ConfigService, SpendService } from 'src/providers'; 24 | import fetch from 'node-fetch-cjs'; 25 | import { getRawTransaction } from './apis'; 26 | import { btc } from './btc'; 27 | import { byteString2Int } from 'scrypt-ts'; 28 | import { updateMerkleTree } from 'src/commands/mint/nft.open-mint'; 29 | import { getMinterInitialTxState as getClosedMinterInitialTxState } from 'src/commands/deploy/nft.closed-mint'; 30 | import { getMinterInitialTxState as getParallelClosedMinterInitialTxState } from 'src/commands/deploy/nft.parallel-closed-mint'; 31 | 32 | export type ContractJSON = { 33 | utxo: { 34 | txId: string; 35 | outputIndex: number; 36 | script: string; 37 | satoshis: number; 38 | }; 39 | txoStateHashes: Array; 40 | state: any; 41 | }; 42 | 43 | export type BalanceJSON = { 44 | blockHeight: number; 45 | balances: Array<{ 46 | tokenId: string; 47 | confirmed: string; 48 | }>; 49 | }; 50 | 51 | export const getCollectionInfo = async function ( 52 | config: ConfigService, 53 | id: string, 54 | ): Promise { 55 | const url = `${config.getTracker()}/api/collections/${id}`; 56 | return fetch(url, config.withProxy()) 57 | .then((res) => res.json()) 58 | .then((res: any) => { 59 | if (res.code === 0) { 60 | if (res.data === null) { 61 | return null; 62 | } 63 | const collection = res.data; 64 | if (collection.metadata.max) { 65 | // convert string to bigint 66 | collection.metadata.max = BigInt(collection.metadata.max); 67 | } 68 | 69 | if (collection.metadata.premine) { 70 | // convert string to bigint 71 | collection.metadata.premine = BigInt(collection.metadata.premine); 72 | } 73 | 74 | if (!collection.collectionAddr) { 75 | const minterP2TR = toP2tr(collection.minterAddr); 76 | const network = config.getNetwork(); 77 | collection.collectionAddr = p2tr2Address( 78 | getNFTContractP2TR(minterP2TR).p2tr, 79 | network, 80 | ); 81 | } 82 | return collection; 83 | } else { 84 | throw new Error(res.msg); 85 | } 86 | }) 87 | .catch((e) => { 88 | logerror(`get collection info failed!`, e); 89 | return null; 90 | }); 91 | }; 92 | 93 | const fetchNftClosedMinterState = async function ( 94 | config: ConfigService, 95 | collectionInfo: CollectionInfo, 96 | txId: string, 97 | vout: number, 98 | ): Promise { 99 | const minterP2TR = toP2tr(collectionInfo.minterAddr); 100 | const nftP2TR = toP2tr(collectionInfo.collectionAddr); 101 | if (txId === collectionInfo.revealTxid) { 102 | const { states } = getClosedMinterInitialTxState( 103 | nftP2TR, 104 | collectionInfo.metadata.max, 105 | ); 106 | return states[vout - 1]; 107 | } 108 | 109 | const txhex = await getRawTransaction(config, txId); 110 | if (txhex instanceof Error) { 111 | logerror(`get raw transaction ${txId} failed!`, txhex); 112 | return null; 113 | } 114 | 115 | const tx = new btc.Transaction(txhex); 116 | 117 | const QUOTAMAXLOCALID_WITNESS_INDEX = 13; 118 | const NEXTLOCALID_WITNESS_INDEX = 6; 119 | 120 | for (let i = 0; i < tx.inputs.length; i++) { 121 | const witnesses = tx.inputs[i].getWitnesses(); 122 | 123 | if (witnesses.length > 2) { 124 | const lockingScriptBuffer = witnesses[witnesses.length - 2]; 125 | const { p2tr } = script2P2TR(lockingScriptBuffer); 126 | if (p2tr === minterP2TR) { 127 | const quotaMaxLocalId = byteString2Int( 128 | witnesses[QUOTAMAXLOCALID_WITNESS_INDEX].toString('hex'), 129 | ); 130 | 131 | const nextLocalId = 132 | byteString2Int(witnesses[NEXTLOCALID_WITNESS_INDEX].toString('hex')) + 133 | 1n; 134 | const preState: NftClosedMinterState = { 135 | nftScript: nftP2TR, 136 | quotaMaxLocalId, 137 | nextLocalId, 138 | }; 139 | return preState; 140 | } 141 | } 142 | } 143 | 144 | return null; 145 | }; 146 | 147 | const fetchNftParallelClosedMinterState = async function ( 148 | config: ConfigService, 149 | collectionInfo: CollectionInfo, 150 | txId: string, 151 | vout: number = 1, 152 | ): Promise { 153 | const minterP2TR = toP2tr(collectionInfo.minterAddr); 154 | const nftP2TR = toP2tr(collectionInfo.collectionAddr); 155 | if (txId === collectionInfo.revealTxid) { 156 | const { states } = getParallelClosedMinterInitialTxState(nftP2TR); 157 | return states[0]; 158 | } 159 | 160 | const txhex = await getRawTransaction(config, txId); 161 | if (txhex instanceof Error) { 162 | logerror(`get raw transaction ${txId} failed!`, txhex); 163 | return null; 164 | } 165 | 166 | const tx = new btc.Transaction(txhex); 167 | 168 | const NEXTLOCALID_WITNESS_INDEX = 6; 169 | 170 | for (let i = 0; i < tx.inputs.length; i++) { 171 | const witnesses = tx.inputs[i].getWitnesses(); 172 | 173 | if (witnesses.length > 2) { 174 | const lockingScriptBuffer = witnesses[witnesses.length - 2]; 175 | const { p2tr } = script2P2TR(lockingScriptBuffer); 176 | if (p2tr === minterP2TR) { 177 | const nextLocalId = 178 | byteString2Int(witnesses[NEXTLOCALID_WITNESS_INDEX].toString('hex')) * 179 | 2n + 180 | BigInt(vout); 181 | const preState: NftParallelClosedMinterState = { 182 | nftScript: nftP2TR, 183 | nextLocalId, 184 | }; 185 | return preState; 186 | } 187 | } 188 | } 189 | 190 | return null; 191 | }; 192 | 193 | export const getNFTClosedMinters = async function ( 194 | config: ConfigService, 195 | collectionInfo: CollectionInfo, 196 | spendSerivce: SpendService, 197 | ): Promise { 198 | const url = `${config.getTracker()}/api/minters/${collectionInfo.collectionId}/utxos?limit=5&offset=${0}`; 199 | return fetch(url, config.withProxy()) 200 | .then((res) => res.json()) 201 | .then((res: any) => { 202 | if (res.code === 0) { 203 | return res.data; 204 | } else { 205 | throw new Error(res.msg); 206 | } 207 | }) 208 | .then(({ utxos: contracts }) => { 209 | if (isNFTClosedMinter(collectionInfo.metadata.minterMd5)) { 210 | return Promise.all( 211 | contracts 212 | .filter((c) => spendSerivce.isUnspent(c.utxo)) 213 | .map(async (c) => { 214 | const protocolState = ProtocolState.fromStateHashList( 215 | c.txoStateHashes as ProtocolStateList, 216 | ); 217 | 218 | if (typeof c.utxo.satoshis === 'string') { 219 | c.utxo.satoshis = parseInt(c.utxo.satoshis); 220 | } 221 | 222 | const preState = await fetchNftClosedMinterState( 223 | config, 224 | collectionInfo, 225 | c.utxo.txId, 226 | c.utxo.outputIndex, 227 | ); 228 | 229 | const nftClosedMinterContract: NFTClosedMinterContract = { 230 | utxo: c.utxo, 231 | state: { 232 | protocolState, 233 | data: preState, 234 | }, 235 | }; 236 | return nftClosedMinterContract; 237 | }), 238 | ); 239 | } else { 240 | throw new Error('Unkown minter!'); 241 | } 242 | }) 243 | .catch((e) => { 244 | logerror(`fetch minters failed, minter: ${collectionInfo.minterAddr}`, e); 245 | return []; 246 | }); 247 | }; 248 | 249 | export const getNFTMinter = async function ( 250 | config: ConfigService, 251 | collectionInfo: CollectionInfo, 252 | spendSerivce: SpendService, 253 | collectionMerkleTree?: NftOpenMinterMerkleTreeData, 254 | ): Promise< 255 | | NFTClosedMinterContract 256 | | NFTOpenMinterContract 257 | | NFTParallelClosedMinterContract 258 | | null 259 | > { 260 | const url = `${config.getTracker()}/api/minters/${collectionInfo.collectionId}/utxos?limit=100&offset=${0}`; 261 | return fetch(url, config.withProxy()) 262 | .then((res) => res.json()) 263 | .then((res: any) => { 264 | if (res.code === 0) { 265 | return res.data; 266 | } else { 267 | throw new Error(res.msg); 268 | } 269 | }) 270 | .then(({ utxos: contracts }) => { 271 | return Promise.all( 272 | contracts 273 | .filter((c) => spendSerivce.isUnspent(c.utxo)) 274 | .map(async (c) => { 275 | const protocolState = ProtocolState.fromStateHashList( 276 | c.txoStateHashes as ProtocolStateList, 277 | ); 278 | 279 | if (typeof c.utxo.satoshis === 'string') { 280 | c.utxo.satoshis = parseInt(c.utxo.satoshis); 281 | } 282 | 283 | let data: any = null; 284 | 285 | if (isNFTClosedMinter(collectionInfo.metadata.minterMd5)) { 286 | data = await fetchNftClosedMinterState( 287 | config, 288 | collectionInfo, 289 | c.utxo.txId, 290 | c.utxo.outputIndex, 291 | ); 292 | } else if ( 293 | isNFTParallelClosedMinter(collectionInfo.metadata.minterMd5) 294 | ) { 295 | data = await fetchNftParallelClosedMinterState( 296 | config, 297 | collectionInfo, 298 | c.utxo.txId, 299 | c.utxo.outputIndex, 300 | ); 301 | } else if (isNFTOpenMinter(collectionInfo.metadata.minterMd5)) { 302 | data = await fetchNftOpenMinterState( 303 | config, 304 | collectionInfo, 305 | c.utxo.txId, 306 | collectionMerkleTree, 307 | ); 308 | } else { 309 | throw new Error('Unkown minter!'); 310 | } 311 | 312 | return { 313 | utxo: c.utxo, 314 | state: { 315 | protocolState, 316 | data, 317 | }, 318 | }; 319 | }), 320 | ); 321 | }) 322 | .then((minters) => { 323 | return minters[0] || null; 324 | }) 325 | .catch((e) => { 326 | logerror(`fetch minters failed, minter: ${collectionInfo.minterAddr}`, e); 327 | return null; 328 | }); 329 | }; 330 | 331 | const fetchNftOpenMinterState = async function ( 332 | config: ConfigService, 333 | collectionInfo: CollectionInfo, 334 | txId: string, 335 | collectionMerkleTree: NftOpenMinterMerkleTreeData, 336 | ): Promise { 337 | const minterP2TR = toP2tr(collectionInfo.minterAddr); 338 | const tokenP2TR = toP2tr(collectionInfo.collectionAddr); 339 | const metadata = collectionInfo.metadata; 340 | if (txId === collectionInfo.revealTxid) { 341 | return { 342 | merkleRoot: collectionMerkleTree.merkleRoot, 343 | nextLocalId: 0n, 344 | nftScript: tokenP2TR, 345 | }; 346 | } 347 | 348 | const txhex = await getRawTransaction(config, txId); 349 | if (txhex instanceof Error) { 350 | logerror(`get raw transaction ${txId} failed!`, txhex); 351 | return null; 352 | } 353 | 354 | const tx = new btc.Transaction(txhex); 355 | 356 | const NEXTLOCALID_WITNESS_INDEX = 6; 357 | 358 | for (let i = 0; i < tx.inputs.length; i++) { 359 | const witnesses = tx.inputs[i].getWitnesses(); 360 | 361 | if (witnesses.length > 2) { 362 | const lockingScriptBuffer = witnesses[witnesses.length - 2]; 363 | const { p2tr } = script2P2TR(lockingScriptBuffer); 364 | if (p2tr === minterP2TR) { 365 | const nextLocalId = 366 | byteString2Int(witnesses[NEXTLOCALID_WITNESS_INDEX].toString('hex')) + 367 | 1n; 368 | updateMerkleTree(collectionMerkleTree, metadata.max, nextLocalId); 369 | const preState: NftOpenMinterState = { 370 | merkleRoot: collectionMerkleTree.merkleRoot, 371 | nftScript: tokenP2TR, 372 | nextLocalId: nextLocalId, 373 | }; 374 | 375 | return preState; 376 | } 377 | } 378 | } 379 | 380 | return null; 381 | }; 382 | 383 | export const getTrackerStatus = async function (config: ConfigService): Promise< 384 | | { 385 | trackerBlockHeight: number; 386 | nodeBlockHeight: number; 387 | latestBlockHeight: number; 388 | } 389 | | Error 390 | > { 391 | const url = `${config.getTracker()}/api`; 392 | return fetch(url, config.withProxy()) 393 | .then((res) => res.json()) 394 | .then((res: any) => { 395 | if (res.code === 0) { 396 | return res.data; 397 | } else { 398 | throw new Error(res.msg); 399 | } 400 | }) 401 | .catch((e) => { 402 | logerror(`fetch tracker status failed`, e); 403 | return e; 404 | }); 405 | }; 406 | 407 | export const getNft = async function ( 408 | config: ConfigService, 409 | collection: CollectionInfo, 410 | localId: bigint, 411 | ): Promise { 412 | const url = `${config.getTracker()}/api/collections/${collection.collectionId}/localId/${localId}/utxo`; 413 | return fetch(url, config.withProxy()) 414 | .then((res) => res.json()) 415 | .then((res: any) => { 416 | if (res.code === 0) { 417 | return res.data; 418 | } else { 419 | throw new Error(res.msg); 420 | } 421 | }) 422 | .then(({ utxo: data }) => { 423 | if (!data) { 424 | return null; 425 | } 426 | const protocolState = ProtocolState.fromStateHashList( 427 | data.txoStateHashes as ProtocolStateList, 428 | ); 429 | 430 | if (typeof data.utxo.satoshis === 'string') { 431 | data.utxo.satoshis = parseInt(data.utxo.satoshis); 432 | } 433 | 434 | const r: NFTContract = { 435 | utxo: data.utxo, 436 | state: { 437 | protocolState, 438 | data: { 439 | ownerAddr: data.state.address, 440 | localId: BigInt(data.state.localId), 441 | }, 442 | }, 443 | }; 444 | 445 | return r; 446 | }) 447 | .catch((e) => { 448 | logerror(`fetch NFTContract failed:`, e); 449 | return null; 450 | }); 451 | }; 452 | 453 | export const getNfts = async function ( 454 | config: ConfigService, 455 | collection: CollectionInfo, 456 | ownerAddress: string, 457 | spendService: SpendService | null = null, 458 | ): Promise<{ 459 | trackerBlockHeight: number; 460 | contracts: Array; 461 | } | null> { 462 | const url = `${config.getTracker()}/api/collections/${collection.collectionId}/addresses/${ownerAddress}/utxos`; 463 | return fetch(url, config.withProxy()) 464 | .then((res) => res.json()) 465 | .then((res: any) => { 466 | if (res.code === 0) { 467 | return res.data; 468 | } else { 469 | throw new Error(res.msg); 470 | } 471 | }) 472 | .then(({ utxos, trackerBlockHeight }) => { 473 | let contracts: Array = utxos.map((c) => { 474 | const protocolState = ProtocolState.fromStateHashList( 475 | c.txoStateHashes as ProtocolStateList, 476 | ); 477 | 478 | if (typeof c.utxo.satoshis === 'string') { 479 | c.utxo.satoshis = parseInt(c.utxo.satoshis); 480 | } 481 | 482 | const r: NFTContract = { 483 | utxo: c.utxo, 484 | state: { 485 | protocolState, 486 | data: { 487 | ownerAddr: c.state.address, 488 | localId: BigInt(c.state.localId), 489 | }, 490 | }, 491 | }; 492 | 493 | return r; 494 | }); 495 | 496 | if (spendService) { 497 | contracts = contracts.filter((tokenContract) => { 498 | return spendService.isUnspent(tokenContract.utxo); 499 | }); 500 | 501 | if (trackerBlockHeight - spendService.blockHeight() > 100) { 502 | spendService.reset(); 503 | } 504 | spendService.updateBlockHeight(trackerBlockHeight); 505 | } 506 | 507 | return { 508 | contracts, 509 | trackerBlockHeight: trackerBlockHeight as number, 510 | }; 511 | }) 512 | .catch((e) => { 513 | logerror(`fetch tokens failed:`, e); 514 | return null; 515 | }); 516 | }; 517 | 518 | export const getCollectionsByOwner = async function ( 519 | config: ConfigService, 520 | ownerAddress: string, 521 | ): Promise> { 522 | const url = `${config.getTracker()}/api/addresses/${ownerAddress}/collections`; 523 | return fetch(url, config.withProxy()) 524 | .then((res) => res.json()) 525 | .then((res: any) => { 526 | if (res.code === 0) { 527 | return res.data; 528 | } else { 529 | throw new Error(res.msg); 530 | } 531 | }) 532 | .then(({ collections }) => { 533 | return collections.map((collection) => { 534 | return collection.collectionId; 535 | }); 536 | }) 537 | .catch((e) => { 538 | logerror(`fetch collections failed:`, e); 539 | return []; 540 | }); 541 | }; 542 | -------------------------------------------------------------------------------- /src/common/apis.ts: -------------------------------------------------------------------------------- 1 | import { UTXO } from 'scrypt-ts'; 2 | import fetch from 'node-fetch-cjs'; 3 | 4 | import { 5 | rpc_broadcast, 6 | rpc_getconfirmations, 7 | rpc_getfeeRate, 8 | rpc_getrawtransaction, 9 | rpc_listunspent, 10 | } from './apis-rpc'; 11 | import { logerror } from './log'; 12 | import { btc } from './btc'; 13 | import { ConfigService, WalletService } from 'src/providers'; 14 | import { sleep } from './utils'; 15 | 16 | export const getFeeRate = async function ( 17 | config: ConfigService, 18 | wallet: WalletService, 19 | ): Promise { 20 | if (config.useRpc()) { 21 | const feeRate = await rpc_getfeeRate(config, wallet.getWalletName()); 22 | if (feeRate instanceof Error) { 23 | return 2; 24 | } 25 | return feeRate; 26 | } 27 | 28 | const url = `${config.getMempoolApiHost()}/api/v1/fees/recommended`; 29 | const feeRate: any = await fetch(url, config.withProxy()) 30 | .then((res) => { 31 | if (res.status === 200) { 32 | return res.json(); 33 | } 34 | return {}; 35 | }) 36 | .catch((e) => { 37 | console.error(`fetch feeRate failed:`, e); 38 | return {}; 39 | }); 40 | 41 | if (!feeRate) { 42 | return 2; 43 | } 44 | 45 | return Math.max(2, feeRate['fastestFee'] || 1); 46 | }; 47 | 48 | export const getFractalUtxos = async function ( 49 | config: ConfigService, 50 | address: btc.Address, 51 | ): Promise { 52 | const script = new btc.Script(address).toHex(); 53 | 54 | const url = `${config.getOpenApiHost()}/v1/indexer/address/${address}/utxo-data?cursor=0&size=16`; 55 | const utxos: Array = await fetch( 56 | url, 57 | config.withProxy({ 58 | method: 'GET', 59 | headers: { 60 | 'Content-Type': 'application/json', 61 | Authorization: `Bearer ${config.getApiKey()}`, 62 | }, 63 | }), 64 | ) 65 | .then(async (res) => { 66 | const contentType = res.headers.get('content-type'); 67 | if (contentType.includes('json')) { 68 | return res.json(); 69 | } else { 70 | throw new Error( 71 | `invalid http content type : ${contentType}, status: ${res.status}`, 72 | ); 73 | } 74 | }) 75 | .then((res: any) => { 76 | if (res.code === 0) { 77 | const { data } = res; 78 | return data.utxo.map((utxo) => { 79 | return { 80 | txId: utxo.txid, 81 | outputIndex: utxo.vout, 82 | script: utxo.scriptPk || script, 83 | satoshis: utxo.satoshi, 84 | }; 85 | }); 86 | } else { 87 | logerror(`fetch utxos failed:`, new Error(res.msg)); 88 | } 89 | return []; 90 | }) 91 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 92 | .catch((e) => { 93 | logerror(`fetch utxos failed:`, e); 94 | return []; 95 | }); 96 | return utxos.sort((a, b) => a.satoshi - b.satoshi); 97 | }; 98 | 99 | export const getUtxos = async function ( 100 | config: ConfigService, 101 | wallet: WalletService, 102 | address: btc.Address, 103 | ): Promise { 104 | if (config.useRpc()) { 105 | const utxos = await rpc_listunspent( 106 | config, 107 | wallet.getWalletName(), 108 | address.toString(), 109 | ); 110 | if (utxos instanceof Error) { 111 | return []; 112 | } 113 | return utxos; 114 | } 115 | 116 | if (config.isFractalNetwork() && !config.useRpc() && config.getApiKey()) { 117 | return getFractalUtxos(config, address); 118 | } 119 | 120 | const script = new btc.Script(address).toHex(); 121 | 122 | const url = `${config.getMempoolApiHost()}/api/address/${address}/utxo`; 123 | const utxos: Array = await fetch(url, config.withProxy()) 124 | .then(async (res) => { 125 | const contentType = res.headers.get('content-type'); 126 | if (contentType.includes('json')) { 127 | return res.json(); 128 | } else { 129 | throw new Error( 130 | `invalid http content type : ${contentType}, status: ${res.status}`, 131 | ); 132 | } 133 | }) 134 | .then((utxos: Array) => 135 | utxos.map((utxo) => { 136 | return { 137 | txId: utxo.txid, 138 | outputIndex: utxo.vout, 139 | script: utxo.script || script, 140 | satoshis: utxo.value, 141 | }; 142 | }), 143 | ) 144 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 145 | .catch((e) => { 146 | console.error(`fetch ${url} failed:`, e); 147 | return []; 148 | }); 149 | return utxos.sort((a, b) => a.satoshi - b.satoshi); 150 | }; 151 | 152 | export const getRawTransaction = async function ( 153 | config: ConfigService, 154 | txid: string, 155 | ): Promise { 156 | if (config.useRpc()) { 157 | return rpc_getrawtransaction(config, txid); 158 | } 159 | const url = `${config.getMempoolApiHost()}/api/tx/${txid}/hex`; 160 | return ( 161 | fetch(url, config.withProxy()) 162 | .then((res) => { 163 | if (res.status === 200) { 164 | return res.text(); 165 | } 166 | new Error(`invalid http response code: ${res.status}`); 167 | }) 168 | .then((txhex: string) => { 169 | return txhex; 170 | }) 171 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 172 | .catch((e: Error) => { 173 | logerror('getrawtransaction failed!', e); 174 | return e; 175 | }) 176 | ); 177 | }; 178 | 179 | export const getConfirmations = async function ( 180 | config: ConfigService, 181 | txid: string, 182 | ): Promise< 183 | | { 184 | blockhash: string; 185 | confirmations: number; 186 | } 187 | | Error 188 | > { 189 | if (config.useRpc()) { 190 | return rpc_getconfirmations(config, txid); 191 | } 192 | const url = `${config.getMempoolApiHost()}/api/tx/${txid}/status`; 193 | return fetch(url, config.withProxy()) 194 | .then(async (res) => { 195 | const contentType = res.headers.get('content-type'); 196 | if (contentType.includes('json')) { 197 | return res.json(); 198 | } else { 199 | return res.text(); 200 | } 201 | }) 202 | .then(async (data) => { 203 | if (typeof data === 'object') { 204 | return { 205 | blockhash: data['block_hash'], 206 | confirmations: data['confirmed'] ? 1 : -1, 207 | }; 208 | } else if (typeof data === 'object') { 209 | throw new Error(JSON.stringify(data)); 210 | } else if (typeof data === 'string') { 211 | throw new Error(data); 212 | } 213 | }) 214 | .catch((e) => { 215 | logerror('getconfirmations failed!', e); 216 | return e; 217 | }); 218 | }; 219 | 220 | export async function waitConfirm(config, txid) { 221 | while (true) { 222 | const confirmationsRes = await getConfirmations(config, txid); 223 | 224 | if (confirmationsRes instanceof Error) { 225 | logerror(`txid ${txid} getConfirmations error`, confirmationsRes); 226 | throw confirmationsRes; 227 | } 228 | 229 | if (confirmationsRes.confirmations > 0) { 230 | console.log( 231 | `tx: ${txid} got ${confirmationsRes.confirmations} confirmed`, 232 | ); 233 | break; 234 | } 235 | console.log(`wait tx: ${txid} to be confirmed ...`); 236 | await sleep(2); 237 | } 238 | } 239 | 240 | export async function broadcast( 241 | config: ConfigService, 242 | wallet: WalletService, 243 | txHex: string, 244 | ): Promise { 245 | if (config.useRpc()) { 246 | return rpc_broadcast(config, wallet.getWalletName(), txHex); 247 | } 248 | 249 | const url = `${config.getMempoolApiHost()}/api/tx`; 250 | return fetch( 251 | url, 252 | config.withProxy({ 253 | method: 'POST', 254 | body: txHex, 255 | }), 256 | ) 257 | .then(async (res) => { 258 | const contentType = res.headers.get('content-type'); 259 | if (contentType.includes('json')) { 260 | return res.json(); 261 | } else { 262 | return res.text(); 263 | } 264 | }) 265 | .then(async (data) => { 266 | if (typeof data === 'string' && data.length === 64) { 267 | return data; 268 | } else if (typeof data === 'object') { 269 | throw new Error(JSON.stringify(data)); 270 | } else if (typeof data === 'string') { 271 | throw new Error(data); 272 | } 273 | }) 274 | .catch((e) => { 275 | logerror('broadcast failed!', e); 276 | return e; 277 | }); 278 | } 279 | -------------------------------------------------------------------------------- /src/common/btc.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-ignore 3 | import btc = require('bitcore-lib-inquisition'); 4 | 5 | export { btc }; 6 | -------------------------------------------------------------------------------- /src/common/cli-config.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute, join } from 'path'; 2 | 3 | export type SupportedNetwork = 4 | | 'btc-signet' 5 | | 'fractal-mainnet' 6 | | 'fractal-testnet'; 7 | export interface CliConfig { 8 | network: SupportedNetwork; 9 | tracker: string; 10 | dataDir: string; 11 | maxFeeRate?: number; 12 | feeRate?: number; 13 | rpc?: { 14 | url: string; 15 | username: string; 16 | password: string; 17 | }; 18 | verify?: boolean; 19 | proxy?: string; 20 | apiKey?: string; 21 | } 22 | 23 | export const resolveConfigPath = (val: string) => { 24 | if (val) { 25 | return isAbsolute(val) ? val : join(process.cwd(), val); 26 | } else { 27 | return join(process.cwd(), 'config.json'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/common/contact.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProtocolState, 3 | OpenMinterState, 4 | CAT20State, 5 | GuardConstState, 6 | CAT721State, 7 | NftClosedMinterState, 8 | NftGuardConstState, 9 | OpenMinterV2State, 10 | NftOpenMinterState, 11 | NftParallelClosedMinterState, 12 | } from '@cat-protocol/cat-smartcontracts'; 13 | import { UTXO } from 'scrypt-ts'; 14 | 15 | export interface ContractState { 16 | protocolState: ProtocolState; 17 | 18 | data: T; 19 | } 20 | 21 | export interface Contract { 22 | utxo: UTXO; 23 | state: ContractState; 24 | } 25 | 26 | export type OpenMinterContract = Contract; 27 | 28 | export type TokenContract = Contract; 29 | 30 | export type GuardContract = Contract; 31 | 32 | export type NFTClosedMinterContract = Contract; 33 | 34 | export type NFTParallelClosedMinterContract = 35 | Contract; 36 | 37 | export type NFTOpenMinterContract = Contract; 38 | 39 | export type NFTContract = Contract; 40 | 41 | export type NFTGuardContract = Contract; 42 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './apis'; 2 | export * from './apis-tracker'; 3 | export * from './metadata'; 4 | export * from './utils'; 5 | export * from './wallet'; 6 | export * from './contact'; 7 | export * from './minter'; 8 | export * from './postage'; 9 | export * from './apis-rpc'; 10 | export * from './cli-config'; 11 | export * from './log'; 12 | export * from './minterFinder'; 13 | export * from './btc'; 14 | -------------------------------------------------------------------------------- /src/common/log.ts: -------------------------------------------------------------------------------- 1 | enum LogLevel { 2 | Info = 0, 3 | Error = 1, 4 | ErrorWithStack = 2, 5 | } 6 | 7 | const logLevel: LogLevel = LogLevel.Error; 8 | 9 | export function logerror(label: string, e: Error) { 10 | if (logLevel === LogLevel.ErrorWithStack) { 11 | console.error(label); 12 | console.error(e); 13 | } else if (logLevel === LogLevel.Error) { 14 | console.error(label); 15 | console.error(e.message); 16 | } else { 17 | console.error(label); 18 | } 19 | } 20 | 21 | export function logwarn(label: string, e: Error) { 22 | if (logLevel === LogLevel.Error) { 23 | console.warn(label); 24 | console.warn(e.message); 25 | } else { 26 | console.warn(label); 27 | } 28 | } 29 | 30 | export function log(message: string, ...optionalParams: any[]) { 31 | console.log(message, ...optionalParams); 32 | } 33 | -------------------------------------------------------------------------------- /src/common/metadata.ts: -------------------------------------------------------------------------------- 1 | export interface CollectionMetadata { 2 | name: string; 3 | symbol: string; 4 | description: string; 5 | max: bigint; 6 | premine?: bigint; 7 | icon?: string; 8 | minterMd5: string; 9 | } 10 | 11 | export interface CollectionInfo { 12 | metadata: CollectionMetadata; 13 | collectionId: string; 14 | /** token p2tr address */ 15 | collectionAddr: string; 16 | /** minter p2tr address */ 17 | minterAddr: string; 18 | genesisTxid: string; 19 | revealTxid: string; 20 | timestamp: number; 21 | } 22 | -------------------------------------------------------------------------------- /src/common/minter.ts: -------------------------------------------------------------------------------- 1 | export enum MinterType { 2 | OPEN_MINTER_V1 = '21cbd2e538f2b6cc40ee180e174f1e25', 3 | OPEN_MINTER_V2 = 'a6c2e92d74a23c07bb6220b676c6cb9b', 4 | NFT_CLOSED_MINTER = 'fc2429e864deaa9b44d1e6f24282334f', 5 | NFT_PARALLEL_CLOSED_MINTER = '12afc68fd84cddd24bc866da2955fb57', 6 | NFT_OPEN_MINTER = '86fe8c275b45c796bb9500b815d48820', 7 | UNKOWN_MINTER = 'unkown_minter', 8 | } 9 | -------------------------------------------------------------------------------- /src/common/minterFinder.ts: -------------------------------------------------------------------------------- 1 | import { MinterType } from './minter'; 2 | 3 | export function isOpenMinter(md5: string) { 4 | return MinterType.OPEN_MINTER_V1 === md5 || MinterType.OPEN_MINTER_V2 === md5; 5 | } 6 | 7 | export function isNFTClosedMinter(md5: string) { 8 | return MinterType.NFT_CLOSED_MINTER === md5; 9 | } 10 | 11 | export function isNFTParallelClosedMinter(md5: string) { 12 | return MinterType.NFT_PARALLEL_CLOSED_MINTER === md5; 13 | } 14 | 15 | export function isNFTOpenMinter(md5: string) { 16 | return MinterType.NFT_OPEN_MINTER === md5; 17 | } 18 | -------------------------------------------------------------------------------- /src/common/postage.ts: -------------------------------------------------------------------------------- 1 | export enum Postage { 2 | METADATA_POSTAGE = 546, 3 | GUARD_POSTAGE = 332, 4 | MINTER_POSTAGE = 331, 5 | TOKEN_POSTAGE = 330, 6 | NFT_POSTAGE = 333, 7 | } 8 | 9 | export const CHANGE_MIN_POSTAGE = 546; 10 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClosedMinter, 3 | NftClosedMinter, 4 | TxOutpoint, 5 | ProtocolState, 6 | BurnGuard, 7 | TransferGuard, 8 | int32, 9 | CAT20, 10 | OpenMinter, 11 | OpenMinterV2, 12 | CAT721, 13 | NftBurnGuard, 14 | NftTransferGuard, 15 | NftClosedMinterProto, 16 | NftOpenMinter, 17 | NftParallelClosedMinter, 18 | } from '@cat-protocol/cat-smartcontracts'; 19 | 20 | import { btc } from './btc'; 21 | import { 22 | bsv, 23 | ByteString, 24 | ContractTransaction, 25 | DummyProvider, 26 | hash160, 27 | int2ByteString, 28 | SmartContract, 29 | TestWallet, 30 | toByteString, 31 | UTXO, 32 | } from 'scrypt-ts'; 33 | 34 | import { Tap } from '@cmdcode/tapscript'; 35 | import { randomBytes } from 'crypto'; 36 | import { SupportedNetwork } from './cli-config'; 37 | import Decimal from 'decimal.js'; 38 | import { MinterType } from './minter'; 39 | 40 | const ISSUE_PUBKEY = 41 | '0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; 42 | 43 | export function toXOnly(pubkey: Buffer): Buffer { 44 | return pubkey.subarray(1, 33); 45 | } 46 | 47 | export const checkDisableOpCode = function (scriptPubKey) { 48 | for (const chunk of scriptPubKey.chunks) { 49 | // New opcodes will be listed here. May use a different sigversion to modify existing opcodes. 50 | if (btc.Opcode.isOpSuccess(chunk.opcodenum)) { 51 | console.log(chunk.opcodenum, btc.Opcode.reverseMap[chunk.opcodenum]); 52 | return true; 53 | } 54 | } 55 | return false; 56 | }; 57 | 58 | export const byteStringToBuffer = function (byteStringList: ByteString[]) { 59 | const bufferList: Buffer[] = []; 60 | for (const byteString of byteStringList) { 61 | bufferList.push(Buffer.from(byteString, 'hex')); 62 | } 63 | return bufferList; 64 | }; 65 | 66 | export function strToByteString(s: string) { 67 | return toByteString(Buffer.from(s, 'utf-8').toString('hex')); 68 | } 69 | 70 | export function contract2P2TR(contract: SmartContract): { 71 | p2tr: string; 72 | tapScript: string; 73 | cblock: string; 74 | contract: SmartContract; 75 | } { 76 | const p2tr = script2P2TR(contract.lockingScript.toBuffer()); 77 | return { 78 | ...p2tr, 79 | contract, 80 | }; 81 | } 82 | 83 | export function script2P2TR(script: Buffer): { 84 | p2tr: string; 85 | tapScript: string; 86 | cblock: string; 87 | } { 88 | const tapScript = Tap.encodeScript(script); 89 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 90 | const [p2tr, cblock] = Tap.getPubKey(ISSUE_PUBKEY, { 91 | target: tapScript, 92 | }); 93 | return { 94 | p2tr: new btc.Script(`OP_1 32 0x${p2tr}}`).toHex(), 95 | tapScript: tapScript, 96 | cblock, 97 | }; 98 | } 99 | 100 | export enum GuardType { 101 | Transfer, 102 | Burn, 103 | } 104 | export function getGuardsP2TR(guardType: GuardType = GuardType.Transfer): { 105 | p2tr: string; 106 | tapScript: string; 107 | cblock: string; 108 | contract: SmartContract; 109 | } { 110 | const burnGuard = new BurnGuard(); 111 | const transferGuard = new TransferGuard(); 112 | const tapleafKeyBurnGuard = Tap.encodeScript( 113 | burnGuard.lockingScript.toBuffer(), 114 | ); 115 | const tapleafKeyTransferGuard = Tap.encodeScript( 116 | transferGuard.lockingScript.toBuffer(), 117 | ); 118 | 119 | const tapTree = [tapleafKeyBurnGuard, tapleafKeyTransferGuard]; 120 | const [tpubkeyGuards] = Tap.getPubKey(ISSUE_PUBKEY, { 121 | tree: tapTree, 122 | }); 123 | 124 | const [, cblockKeyBurnGuard] = Tap.getPubKey(ISSUE_PUBKEY, { 125 | target: tapleafKeyBurnGuard, 126 | tree: tapTree, 127 | }); 128 | const [, cblockKeyTransferGuard] = Tap.getPubKey(ISSUE_PUBKEY, { 129 | target: tapleafKeyTransferGuard, 130 | tree: tapTree, 131 | }); 132 | 133 | const p2tr = new btc.Script(`OP_1 32 0x${tpubkeyGuards}}`).toHex(); 134 | 135 | if (guardType === GuardType.Transfer) { 136 | return { 137 | p2tr, 138 | tapScript: tapleafKeyTransferGuard, 139 | cblock: cblockKeyTransferGuard, 140 | contract: transferGuard, 141 | }; 142 | } else if (guardType === GuardType.Burn) { 143 | return { 144 | p2tr, 145 | tapScript: tapleafKeyBurnGuard, 146 | cblock: cblockKeyBurnGuard, 147 | contract: burnGuard, 148 | }; 149 | } 150 | } 151 | 152 | export function getNFTGuardsP2TR(guardType: GuardType = GuardType.Transfer): { 153 | p2tr: string; 154 | tapScript: string; 155 | cblock: string; 156 | contract: SmartContract; 157 | } { 158 | const burnGuard = new NftBurnGuard(); 159 | const transferGuard = new NftTransferGuard(); 160 | const tapleafKeyBurnGuard = Tap.encodeScript( 161 | burnGuard.lockingScript.toBuffer(), 162 | ); 163 | const tapleafKeyTransferGuard = Tap.encodeScript( 164 | transferGuard.lockingScript.toBuffer(), 165 | ); 166 | 167 | const tapTree = [tapleafKeyBurnGuard, tapleafKeyTransferGuard]; 168 | const [tpubkeyGuards] = Tap.getPubKey(ISSUE_PUBKEY, { 169 | tree: tapTree, 170 | }); 171 | 172 | const [, cblockKeyBurnGuard] = Tap.getPubKey(ISSUE_PUBKEY, { 173 | target: tapleafKeyBurnGuard, 174 | tree: tapTree, 175 | }); 176 | const [, cblockKeyTransferGuard] = Tap.getPubKey(ISSUE_PUBKEY, { 177 | target: tapleafKeyTransferGuard, 178 | tree: tapTree, 179 | }); 180 | 181 | const p2tr = new btc.Script(`OP_1 32 0x${tpubkeyGuards}}`).toHex(); 182 | 183 | if (guardType === GuardType.Transfer) { 184 | return { 185 | p2tr, 186 | tapScript: tapleafKeyTransferGuard, 187 | cblock: cblockKeyTransferGuard, 188 | contract: transferGuard, 189 | }; 190 | } else if (guardType === GuardType.Burn) { 191 | return { 192 | p2tr, 193 | tapScript: tapleafKeyBurnGuard, 194 | cblock: cblockKeyBurnGuard, 195 | contract: burnGuard, 196 | }; 197 | } 198 | } 199 | 200 | export function getTokenContract(minterP2TR: string, guardsP2TR: string) { 201 | return new CAT20(minterP2TR, toByteString(guardsP2TR)); 202 | } 203 | 204 | export function getNFTContract(minterP2TR: string, guardsP2TR: string) { 205 | return new CAT721(minterP2TR, toByteString(guardsP2TR)); 206 | } 207 | 208 | export function getTokenContractP2TR(minterP2TR: string) { 209 | const { p2tr: guardsP2TR } = getGuardsP2TR(); 210 | return contract2P2TR(getTokenContract(minterP2TR, guardsP2TR)); 211 | } 212 | 213 | export function getNFTContractP2TR(minterP2TR: string) { 214 | const { p2tr: guardsP2TR } = getNFTGuardsP2TR(); 215 | return contract2P2TR(getNFTContract(minterP2TR, guardsP2TR)); 216 | } 217 | 218 | export function getClosedMinterContract( 219 | issuerAddress: string, 220 | genesisId: ByteString, 221 | ) { 222 | return new ClosedMinter(issuerAddress, genesisId); 223 | } 224 | 225 | export function getClosedNFTMinterContract( 226 | issuerAddress: string, 227 | genesisId: ByteString, 228 | max: int32, 229 | ) { 230 | return new NftClosedMinter(issuerAddress, genesisId, max); 231 | } 232 | 233 | export function getParallelClosedNFTMinterContract( 234 | issuerAddress: string, 235 | genesisId: ByteString, 236 | max: int32, 237 | ) { 238 | return new NftParallelClosedMinter(issuerAddress, genesisId, max); 239 | } 240 | 241 | export function getNFTOpenMinterContract( 242 | genesisId: ByteString, 243 | max: int32, 244 | premine: int32, 245 | premineAddr: ByteString, 246 | ) { 247 | return new NftOpenMinter(genesisId, max, premine, premineAddr); 248 | } 249 | 250 | export function getOpenMinterContract( 251 | genesisId: ByteString, 252 | max: int32, 253 | premine: int32, 254 | limit: int32, 255 | premineAddress: ByteString, 256 | minterMd5: string = MinterType.OPEN_MINTER_V2, 257 | ) { 258 | if (minterMd5 === MinterType.OPEN_MINTER_V1) { 259 | return new OpenMinter(genesisId, max, premine, limit, premineAddress); 260 | } 261 | const maxCount = max / limit; 262 | const premineCount = premine / limit; 263 | return new OpenMinterV2( 264 | genesisId, 265 | maxCount, 266 | premine, 267 | premineCount, 268 | limit, 269 | premineAddress, 270 | ); 271 | } 272 | 273 | export function getOpenMinterContractP2TR( 274 | genesisId: ByteString, 275 | max: int32, 276 | premine: int32, 277 | limit: int32, 278 | premineAddress: ByteString, 279 | minterMd5: string, 280 | ) { 281 | return contract2P2TR( 282 | getOpenMinterContract( 283 | genesisId, 284 | max, 285 | premine, 286 | limit, 287 | premineAddress, 288 | minterMd5, 289 | ), 290 | ); 291 | } 292 | 293 | export function getNftOpenMinterContractP2TR( 294 | genesisId: ByteString, 295 | max: int32, 296 | premine: int32, 297 | premineAddr: ByteString, 298 | ) { 299 | return contract2P2TR( 300 | getNFTOpenMinterContract(genesisId, max, premine, premineAddr), 301 | ); 302 | } 303 | 304 | export function getClosedMinterContractP2TR( 305 | issuerAddress: string, 306 | genesisId: ByteString, 307 | ) { 308 | return contract2P2TR(getClosedMinterContract(issuerAddress, genesisId)); 309 | } 310 | 311 | export function getNftClosedMinterContractP2TR( 312 | issuerAddress: string, 313 | genesisId: ByteString, 314 | max: int32, 315 | ) { 316 | return contract2P2TR( 317 | getClosedNFTMinterContract(issuerAddress, genesisId, max), 318 | ); 319 | } 320 | 321 | export function getNftParallelClosedMinterContractP2TR( 322 | issuerAddress: string, 323 | genesisId: ByteString, 324 | max: int32, 325 | ) { 326 | return contract2P2TR( 327 | getParallelClosedNFTMinterContract(issuerAddress, genesisId, max), 328 | ); 329 | } 330 | 331 | export function toTxOutpoint(txid: string, outputIndex: number): TxOutpoint { 332 | const outputBuf = Buffer.alloc(4, 0); 333 | outputBuf.writeUInt32LE(outputIndex); 334 | return { 335 | txhash: Buffer.from(txid, 'hex').reverse().toString('hex'), 336 | outputIndex: outputBuf.toString('hex'), 337 | }; 338 | } 339 | 340 | export function outpoint2TxOutpoint(outpoint: string): TxOutpoint { 341 | const [txid, vout] = outpoint.split('_'); 342 | return toTxOutpoint(txid, parseInt(vout)); 343 | } 344 | 345 | export const outpoint2ByteString = function (outpoint: string) { 346 | const txOutpoint = outpoint2TxOutpoint(outpoint); 347 | return txOutpoint.txhash + txOutpoint.outputIndex; 348 | }; 349 | 350 | export function getDummySigner( 351 | privateKey?: bsv.PrivateKey | bsv.PrivateKey[], 352 | ): TestWallet { 353 | if (global.dummySigner === undefined) { 354 | global.dummySigner = new TestWallet( 355 | bsv.PrivateKey.fromWIF( 356 | 'cRn63kHoi3EWnYeT4e8Fz6rmGbZuWkDtDG5qHnEZbmE5mGvENhrv', 357 | ), 358 | new DummyProvider(), 359 | ); 360 | } 361 | if (privateKey !== undefined) { 362 | global.dummySigner.addPrivateKey(privateKey); 363 | } 364 | return global.dummySigner; 365 | } 366 | 367 | export const dummyUTXO = { 368 | txId: randomBytes(32).toString('hex'), 369 | outputIndex: 0, 370 | script: '', // placeholder 371 | satoshis: 10000, 372 | }; 373 | 374 | export function getDummyUTXO(satoshis: number = 10000, unique = false): UTXO { 375 | if (unique) { 376 | return Object.assign({}, dummyUTXO, { 377 | satoshis, 378 | txId: randomBytes(32).toString('hex'), 379 | }); 380 | } 381 | return Object.assign({}, dummyUTXO, { satoshis }); 382 | } 383 | 384 | export const callToBufferList = function (ct: ContractTransaction) { 385 | const callArgs = ct.tx.inputs[ct.atInputIndex].script.chunks.map((value) => { 386 | if (!value.buf) { 387 | if (value.opcodenum >= 81 && value.opcodenum <= 96) { 388 | const hex = int2ByteString(BigInt(value.opcodenum - 80)); 389 | return Buffer.from(hex, 'hex'); 390 | } else { 391 | return Buffer.from(toByteString('')); 392 | } 393 | } 394 | return value.buf; 395 | }); 396 | return callArgs; 397 | }; 398 | 399 | export const toStateScript = function (state: ProtocolState) { 400 | return new btc.Script(`6a1863617401${state.hashRoot}`); 401 | }; 402 | 403 | export function toBitcoinNetwork(network: SupportedNetwork): btc.Network { 404 | if (network === 'btc-signet') { 405 | return btc.Networks.testnet; 406 | } else if (network === 'fractal-mainnet' || 'fractal-testnet') { 407 | return btc.Networks.mainnet; 408 | } else { 409 | throw new Error(`invalid network ${network}`); 410 | } 411 | } 412 | 413 | export function p2tr2Address( 414 | p2tr: string | btc.Script, 415 | network: SupportedNetwork, 416 | ) { 417 | const script = typeof p2tr === 'string' ? btc.Script.fromHex(p2tr) : p2tr; 418 | return btc.Address.fromScript(script, toBitcoinNetwork(network)).toString(); 419 | } 420 | 421 | export function toP2tr(address: string | btc.Address): string { 422 | const p2trAddress = 423 | typeof address === 'string' ? btc.Address.fromString(address) : address; 424 | 425 | if (p2trAddress.type !== 'taproot') { 426 | throw new Error(`address ${address} is not taproot`); 427 | } 428 | 429 | return btc.Script.fromAddress(address).toHex(); 430 | } 431 | 432 | export function scaleByDecimals(amount: bigint, decimals: number) { 433 | return amount * BigInt(Math.pow(10, decimals)); 434 | } 435 | 436 | export function unScaleByDecimals(amount: bigint, decimals: number): string { 437 | return new Decimal(amount.toString().replace('n', '')) 438 | .div(Math.pow(10, decimals)) 439 | .toFixed(decimals); 440 | } 441 | 442 | export function resetTx(tx: btc.Transaction) { 443 | for (let i = 0; i < tx.inputs.length; i++) { 444 | const input = tx.inputs[i]; 445 | if (input.hasWitnesses()) { 446 | input.setWitnesses([]); 447 | } 448 | } 449 | tx.nLockTime = 0; 450 | } 451 | 452 | export function toTokenAddress(address: btc.Address | string): string { 453 | if (typeof address === 'string') { 454 | address = btc.Address.fromString(address); 455 | } 456 | if (address.type === btc.Address.PayToTaproot) { 457 | return hash160(address.hashBuffer.toString('hex')); 458 | } else if (address.type === btc.Address.PayToWitnessPublicKeyHash) { 459 | return address.hashBuffer.toString('hex'); 460 | } else { 461 | throw new Error(`Unsupported address type: ${address.type}`); 462 | } 463 | } 464 | 465 | export function sleep(seconds: number) { 466 | return new Promise(function (resolve) { 467 | setTimeout(resolve, seconds * 1000); 468 | }); 469 | } 470 | 471 | export function needRetry(e: Error) { 472 | return ( 473 | e instanceof Error && 474 | (e.message.includes('txn-mempool-conflict') || 475 | e.message.includes('bad-txns-inputs-missingorspent') || 476 | e.message.includes('Transaction already in block chain') || 477 | e.message.includes('mempool min fee not met')) 478 | ); 479 | } 480 | 481 | export function checkOpenMintMetadata(info: any): Error | null { 482 | if (typeof info.name === 'undefined') { 483 | return new Error(`No token name provided!`); 484 | } 485 | 486 | if (typeof info.name !== 'string') { 487 | return new Error(`Invalid token name!`); 488 | } 489 | 490 | if (typeof info.symbol === 'undefined') { 491 | return new Error(`No token symbol provided!`); 492 | } 493 | 494 | if (typeof info.symbol !== 'string') { 495 | return new Error(`Invalid token symbol!`); 496 | } 497 | 498 | if (typeof info.max === 'undefined') { 499 | return new Error(`No token max supply provided!`); 500 | } 501 | 502 | if (typeof info.max === 'string') { 503 | try { 504 | info.max = BigInt(info.max); 505 | } catch (error) { 506 | return error; 507 | } 508 | } else if (typeof info.max !== 'bigint') { 509 | return new Error(`Invalid token max supply!`); 510 | } 511 | 512 | if (typeof info.premine === 'string') { 513 | try { 514 | info.premine = BigInt(info.premine); 515 | } catch (error) { 516 | return error; 517 | } 518 | } else if (typeof info.premine !== 'bigint') { 519 | return new Error(`Invalid token premine!`); 520 | } 521 | } 522 | 523 | export function checkClosedMintMetadata(info: any): Error | null { 524 | if (typeof info.name === 'undefined') { 525 | return new Error(`No token name provided!`); 526 | } 527 | 528 | if (typeof info.name !== 'string') { 529 | return new Error(`Invalid token name!`); 530 | } 531 | 532 | if (typeof info.symbol === 'undefined') { 533 | return new Error(`No token symbol provided!`); 534 | } 535 | 536 | if (typeof info.symbol !== 'string') { 537 | return new Error(`Invalid token symbol!`); 538 | } 539 | 540 | if (typeof info.max === 'undefined') { 541 | return new Error(`No token max supply provided!`); 542 | } 543 | 544 | if (typeof info.max === 'string') { 545 | try { 546 | info.max = BigInt(info.max); 547 | } catch (error) { 548 | return error; 549 | } 550 | } else if (typeof info.max !== 'bigint') { 551 | return new Error(`Invalid token max supply!`); 552 | } 553 | } 554 | 555 | export function verifyContract( 556 | utxo: UTXO, 557 | tx: btc.Transaction, 558 | inputIndex: number, 559 | witnesses: Buffer[], 560 | ): string | true { 561 | const interpreter = new btc.Script.Interpreter(); 562 | const flags = 563 | btc.Script.Interpreter.SCRIPT_VERIFY_WITNESS | 564 | btc.Script.Interpreter.SCRIPT_VERIFY_TAPROOT; 565 | const res = interpreter.verify( 566 | new btc.Script(''), 567 | new btc.Script(utxo.script), 568 | tx, 569 | inputIndex, 570 | flags, 571 | witnesses, 572 | utxo.satoshis, 573 | ); 574 | if (!res) { 575 | return interpreter.errstr; 576 | } 577 | return true; 578 | } 579 | -------------------------------------------------------------------------------- /src/common/wallet.ts: -------------------------------------------------------------------------------- 1 | export enum AddressType { 2 | P2WPKH = 'p2wpkh', 3 | P2TR = 'p2tr', 4 | } 5 | 6 | export interface Wallet { 7 | name: string; 8 | accountPath: string; 9 | addressType?: AddressType; 10 | mnemonic: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { CommandFactory } from 'nest-commander'; 2 | import { AppModule } from './app.module'; 3 | import { logerror } from './common'; 4 | 5 | export async function bootstrap() { 6 | try { 7 | await CommandFactory.run(AppModule); 8 | } catch (error) { 9 | logerror('bootstrap failed!', error); 10 | } 11 | } 12 | 13 | if (require.main === module) { 14 | bootstrap(); 15 | } 16 | -------------------------------------------------------------------------------- /src/providers/configService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { accessSync, constants, readFileSync } from 'fs'; 3 | import { CliConfig } from 'src/common'; 4 | import { isAbsolute, join } from 'path'; 5 | import { HttpsProxyAgent } from 'https-proxy-agent'; 6 | 7 | @Injectable() 8 | export class ConfigService { 9 | constructor() {} 10 | cliConfig: CliConfig = { 11 | network: 'fractal-mainnet', 12 | tracker: 'http://127.0.0.1:3000', 13 | dataDir: '.', 14 | feeRate: -1, 15 | maxFeeRate: -1, 16 | rpc: null, 17 | }; 18 | 19 | mergeCliConfig(one: CliConfig) { 20 | const rpc = {}; 21 | if (this.cliConfig.rpc !== null) { 22 | Object.assign(rpc, this.cliConfig.rpc); 23 | } 24 | 25 | if (one.rpc !== null && typeof one.rpc === 'object') { 26 | Object.assign(rpc, one.rpc); 27 | } 28 | 29 | Object.assign(this.cliConfig, one); 30 | 31 | if (Object.keys(rpc).length > 0) { 32 | Object.assign(this.cliConfig, { 33 | rpc: rpc, 34 | }); 35 | } 36 | } 37 | 38 | loadCliConfig(configPath: string): Error | null { 39 | try { 40 | accessSync(configPath, constants.R_OK | constants.W_OK); 41 | } catch (error) { 42 | return new Error(`can\'t access config file: \'${configPath}\'`); 43 | } 44 | 45 | try { 46 | const config = JSON.parse(readFileSync(configPath, 'utf8')); 47 | this.mergeCliConfig(config); 48 | return null; 49 | } catch (error) { 50 | return error; 51 | } 52 | } 53 | 54 | getCliConfig(): CliConfig { 55 | return this.cliConfig; 56 | } 57 | 58 | getOpenApiHost = () => { 59 | const config = this.getCliConfig(); 60 | if (config.network === 'fractal-testnet') { 61 | return 'https://open-api-fractal-testnet.unisat.io'; 62 | } else if (config.network === 'fractal-mainnet') { 63 | return 'https://open-api-fractal.unisat.io'; 64 | } else { 65 | throw new Error(`Unsupport network: ${config.network}`); 66 | } 67 | }; 68 | 69 | getMempoolApiHost = () => { 70 | const config = this.getCliConfig(); 71 | if (config.network === 'btc-signet') { 72 | return 'https://mempool.space/signet'; 73 | } else if (config.network === 'fractal-testnet') { 74 | return 'https://mempool-testnet.fractalbitcoin.io'; 75 | } else if (config.network === 'fractal-mainnet') { 76 | return 'https://mempool.fractalbitcoin.io'; 77 | } else { 78 | throw new Error(`Unsupport network: ${config.network}`); 79 | } 80 | }; 81 | 82 | getProxy = () => { 83 | const config = this.getCliConfig(); 84 | return config.proxy; 85 | }; 86 | 87 | getTracker = () => { 88 | const config = this.getCliConfig(); 89 | return config.tracker; 90 | }; 91 | 92 | getApiKey = () => { 93 | const config = this.getCliConfig(); 94 | return config.apiKey; 95 | }; 96 | 97 | getFeeRate = () => { 98 | const config = this.getCliConfig(); 99 | return config.feeRate; 100 | }; 101 | 102 | getMaxFeeRate = () => { 103 | const config = this.getCliConfig(); 104 | return config.maxFeeRate; 105 | }; 106 | 107 | getVerify = () => { 108 | const config = this.getCliConfig(); 109 | return config.verify || false; 110 | }; 111 | 112 | getRpc = (): null | { 113 | url: string; 114 | username: string; 115 | password: string; 116 | } => { 117 | const config = this.getCliConfig(); 118 | return config.rpc; 119 | }; 120 | 121 | getRpcUser = () => { 122 | const config = this.getCliConfig(); 123 | if (config.rpc !== null) { 124 | return config.rpc.username; 125 | } 126 | 127 | throw new Error(`No rpc config found`); 128 | }; 129 | 130 | getRpcPassword = () => { 131 | const config = this.getCliConfig(); 132 | if (config.rpc !== null) { 133 | return config.rpc.password; 134 | } 135 | 136 | throw new Error(`No rpc config found`); 137 | }; 138 | 139 | getRpcUrl = (wallet: string | null) => { 140 | const config = this.getCliConfig(); 141 | if (config.rpc !== null) { 142 | return wallet === null 143 | ? config.rpc.url 144 | : `${config.rpc.url}/wallet/${wallet}`; 145 | } 146 | throw new Error(`No rpc config found`); 147 | }; 148 | 149 | useRpc = () => { 150 | const rpc = this.getRpc(); 151 | return rpc !== null; 152 | }; 153 | 154 | getNetwork = () => { 155 | const config = this.getCliConfig(); 156 | return config.network; 157 | }; 158 | 159 | isFractalNetwork = () => { 160 | const config = this.getCliConfig(); 161 | return config.network.startsWith('fractal'); 162 | }; 163 | 164 | getDataDir(): string { 165 | const config = this.getCliConfig(); 166 | const dataDir = config.dataDir; 167 | if (dataDir) { 168 | return isAbsolute(dataDir) ? dataDir : join(process.cwd(), dataDir); 169 | } else { 170 | return process.cwd(); 171 | } 172 | } 173 | 174 | withProxy(options?: object) { 175 | if (this.getProxy()) { 176 | options = options || {}; 177 | Object.assign(options, { 178 | agent: new HttpsProxyAgent(this.getProxy()), 179 | }); 180 | } 181 | return options; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from './configService'; 3 | import { WalletService } from './walletService'; 4 | import { SpendService } from './spendService'; 5 | 6 | @Module({ 7 | providers: [ConfigService, WalletService, SpendService], 8 | }) 9 | export class Providers {} 10 | 11 | export * from './configService'; 12 | export * from './walletService'; 13 | export * from './spendService'; 14 | -------------------------------------------------------------------------------- /src/providers/spendService.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { UTXO } from 'scrypt-ts'; 3 | import { ConfigService } from './configService'; 4 | import { join } from 'path'; 5 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 6 | import { logerror, btc } from 'src/common'; 7 | 8 | @Injectable() 9 | export class SpendService { 10 | private spends: Set = new Set(); 11 | private _blockHeight = 0; 12 | constructor(@Inject() private readonly configService: ConfigService) { 13 | this.loadSpends(); 14 | } 15 | 16 | loadSpends() { 17 | const dataDir = this.configService.getDataDir(); 18 | const spendFile = join(dataDir, 'spends.json'); 19 | let spendString = null; 20 | 21 | try { 22 | spendString = readFileSync(spendFile).toString(); 23 | } catch (error) { 24 | if (!existsSync(spendFile)) { 25 | return; 26 | } 27 | logerror(`read spend file: ${spendFile} failed!`, error); 28 | return; 29 | } 30 | 31 | try { 32 | const json = JSON.parse(spendString); 33 | 34 | const { blockHeight, spends } = json; 35 | this._blockHeight = blockHeight; 36 | 37 | for (const spend of spends) { 38 | this.addSpend(spend); 39 | } 40 | } catch (error) { 41 | logerror(`parse spend file failed!`, error); 42 | } 43 | } 44 | 45 | addSpend(spend: UTXO | string) { 46 | if (typeof spend === 'string') { 47 | this.spends.add(spend); 48 | } else { 49 | const utxo = spend as UTXO; 50 | this.spends.add(`${utxo.txId}:${utxo.outputIndex}`); 51 | } 52 | } 53 | 54 | isUnspent(utxo: UTXO | string): boolean { 55 | if (typeof utxo === 'string') { 56 | return !this.spends.has(utxo); 57 | } 58 | return !this.spends.has(`${utxo.txId}:${utxo.outputIndex}`); 59 | } 60 | 61 | updateSpends(tx: btc.Transaction) { 62 | for (let i = 0; i < tx.inputs.length - 1; i++) { 63 | const input = tx.inputs[i]; 64 | this.addSpend(`${input.prevTxId.toString('hex')}:${input.outputIndex}`); 65 | } 66 | } 67 | 68 | updateTxsSpends(txs: btc.Transaction[]) { 69 | for (let i = 0; i < txs.length - 1; i++) { 70 | this.updateSpends(txs[i]); 71 | } 72 | } 73 | 74 | blockHeight(): number { 75 | return this._blockHeight; 76 | } 77 | 78 | reset() { 79 | this.spends.clear(); 80 | } 81 | 82 | updateBlockHeight(blockHeight: number): void { 83 | this._blockHeight = blockHeight; 84 | } 85 | 86 | save(): void { 87 | const dataDir = this.configService.getDataDir(); 88 | const spendsFile = join(dataDir, 'spends.json'); 89 | try { 90 | writeFileSync( 91 | spendsFile, 92 | JSON.stringify( 93 | { 94 | blockHeight: this._blockHeight, 95 | spends: Array.from(this.spends), 96 | }, 97 | null, 98 | 1, 99 | ), 100 | ); 101 | return null; 102 | } catch (error) { 103 | logerror(`write spends file: ${spendsFile} failed!`, error); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/providers/walletService.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-ignore 3 | import btc = require('bitcore-lib-inquisition'); 4 | import * as bip39 from 'bip39'; 5 | import BIP32Factory from 'bip32'; 6 | import * as ecc from 'tiny-secp256k1'; 7 | import { Inject, Injectable } from '@nestjs/common'; 8 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 9 | import { 10 | AddressType, 11 | logerror, 12 | rpc_create_watchonly_wallet, 13 | rpc_importdescriptors, 14 | toXOnly, 15 | Wallet, 16 | } from 'src/common'; 17 | import { ConfigService } from './configService'; 18 | import { join } from 'path'; 19 | import { hash160 } from 'scrypt-ts'; 20 | 21 | const bip32 = BIP32Factory(ecc); 22 | 23 | @Injectable() 24 | export class WalletService { 25 | private wallet: Wallet | null = null; 26 | constructor(@Inject() private readonly configService: ConfigService) {} 27 | 28 | checkWalletJson(obj: any) { 29 | if (typeof obj.name === 'undefined') { 30 | throw new Error('No "name" found in wallet.json!'); 31 | } 32 | 33 | if (typeof obj.name !== 'string') { 34 | throw new Error('"name" in wallet.json should be string!'); 35 | } 36 | 37 | if (typeof obj.mnemonic === 'undefined') { 38 | throw new Error('No "mnemonic" found in wallet.json!'); 39 | } 40 | 41 | if (typeof obj.mnemonic !== 'string') { 42 | throw new Error('"mnemonic" in wallet.json should be string!'); 43 | } 44 | 45 | if (!bip39.validateMnemonic(obj.mnemonic)) { 46 | throw new Error('Invalid mnemonic in wallet.json!'); 47 | } 48 | 49 | if (typeof obj.accountPath === 'undefined') { 50 | throw new Error('No "accountPath" found in wallet.json!'); 51 | } 52 | 53 | if (typeof obj.accountPath !== 'string') { 54 | throw new Error('"accountPath" in wallet.json should be string!'); 55 | } 56 | } 57 | 58 | loadWallet(): Wallet | null { 59 | const dataDir = this.configService.getDataDir(); 60 | const walletFile = join(dataDir, 'wallet.json'); 61 | let walletString = null; 62 | 63 | try { 64 | walletString = readFileSync(walletFile).toString(); 65 | } catch (error) { 66 | if (!existsSync(walletFile)) { 67 | logerror( 68 | `wallet file: ${walletFile} not exists!`, 69 | new Error("run 'wallet create' command to create a wallet."), 70 | ); 71 | } else { 72 | logerror(`read wallet file: ${walletFile} failed!`, error); 73 | } 74 | return null; 75 | } 76 | 77 | try { 78 | const wallet = JSON.parse(walletString); 79 | this.checkWalletJson(wallet); 80 | this.wallet = wallet; 81 | return wallet; 82 | } catch (error) { 83 | logerror(`parse wallet file failed!`, error); 84 | } 85 | 86 | return null; 87 | } 88 | 89 | getWallet() { 90 | return this.wallet; 91 | } 92 | 93 | getWalletName() { 94 | return this.wallet.name; 95 | } 96 | 97 | getAddressType = () => { 98 | const wallet = this.getWallet(); 99 | if (wallet === null) { 100 | throw new Error("run 'create wallet' command to create a wallet."); 101 | } 102 | return wallet.addressType || AddressType.P2TR; 103 | }; 104 | 105 | getAccountPath = () => { 106 | const wallet = this.getWallet(); 107 | if (wallet === null) { 108 | throw new Error("run 'create wallet' command to create a wallet."); 109 | } 110 | return wallet.accountPath || ''; 111 | }; 112 | 113 | getMnemonic = () => { 114 | const wallet = this.getWallet(); 115 | if (wallet === null) { 116 | throw new Error("run 'create wallet' command to create a wallet."); 117 | } 118 | return wallet.mnemonic; 119 | }; 120 | 121 | getWif(): string { 122 | return this.getPrivateKey().toWIF(); 123 | } 124 | 125 | getPrivateKey(derivePath?: string): btc.PrivateKey { 126 | const mnemonic = this.getMnemonic(); 127 | const network = btc.Networks.mainnet; 128 | return derivePrivateKey( 129 | mnemonic, 130 | derivePath || this.getAccountPath(), 131 | network, 132 | ); 133 | } 134 | 135 | /** 136 | * Generate a derive path from the given seed 137 | */ 138 | generateDerivePath(seed: string): string { 139 | const path = ['m']; 140 | const hash = Buffer.from(hash160(seed), 'hex'); 141 | for (let i = 0; i < hash.length; i += 4) { 142 | let index = hash.readUint32BE(i); 143 | let hardened = ''; 144 | if (index >= 0x80000000) { 145 | index -= 0x80000000; 146 | hardened = "'"; 147 | } 148 | path.push(`${index}${hardened}`); 149 | } 150 | return path.join('/'); 151 | } 152 | 153 | getP2TRAddress(): btc.Address { 154 | return this.getPrivateKey().toAddress(null, btc.Address.PayToTaproot); 155 | } 156 | 157 | getAddress(): btc.Address { 158 | return this.getP2TRAddress(); 159 | } 160 | 161 | getXOnlyPublicKey(): string { 162 | const pubkey = this.getPublicKey(); 163 | return toXOnly(pubkey.toBuffer()).toString('hex'); 164 | } 165 | 166 | getTweakedPrivateKey(): btc.PrivateKey { 167 | const { tweakedPrivKey } = this.getPrivateKey().createTapTweak(); 168 | return btc.PrivateKey.fromBuffer(tweakedPrivKey); 169 | } 170 | 171 | getPublicKey(): btc.PublicKey { 172 | const addressType = this.getAddressType(); 173 | 174 | if (addressType === AddressType.P2TR) { 175 | return this.getTweakedPrivateKey().toPublicKey(); 176 | } else if (addressType === AddressType.P2WPKH) { 177 | return this.getPrivateKey().toPublicKey(); 178 | } 179 | } 180 | 181 | getPubKeyPrefix(): string { 182 | const addressType = this.getAddressType(); 183 | if (addressType === AddressType.P2TR) { 184 | return ''; 185 | } else if (addressType === AddressType.P2WPKH) { 186 | const pubkey = this.getPublicKey(); 187 | return pubkey.toString().slice(0, 2); 188 | } 189 | } 190 | 191 | getTokenAddress(): string { 192 | const addressType = this.getAddressType(); 193 | 194 | if (addressType === AddressType.P2TR) { 195 | const xpubkey = this.getXOnlyPublicKey(); 196 | return hash160(xpubkey); 197 | } else if (addressType === AddressType.P2WPKH) { 198 | const pubkey = this.getPublicKey(); 199 | return hash160(pubkey.toString()); 200 | } else { 201 | throw new Error(`Unsupported address type: ${addressType}`); 202 | } 203 | } 204 | 205 | getTaprootPrivateKey(): string { 206 | return this.getTweakedPrivateKey(); 207 | } 208 | 209 | getTokenPrivateKey(): string { 210 | const addressType = this.getAddressType(); 211 | 212 | if (addressType === AddressType.P2TR) { 213 | return this.getTaprootPrivateKey(); 214 | } else if (addressType === AddressType.P2WPKH) { 215 | return this.getPrivateKey(); 216 | } else { 217 | throw new Error(`Unsupported address type: ${addressType}`); 218 | } 219 | } 220 | 221 | createWallet(wallet: Wallet): Error | null { 222 | const dataDir = this.configService.getDataDir(); 223 | const walletFile = join(dataDir, 'wallet.json'); 224 | try { 225 | writeFileSync(walletFile, JSON.stringify(wallet, null, 2)); 226 | this.wallet = wallet; 227 | return null; 228 | } catch (error) { 229 | logerror(`write wallet file: ${walletFile} failed!`, error); 230 | return error; 231 | } 232 | } 233 | 234 | async importWallet(create: boolean = false): Promise { 235 | if (create) { 236 | const e = await rpc_create_watchonly_wallet( 237 | this.configService, 238 | this.wallet.name, 239 | ); 240 | if (e instanceof Error) { 241 | logerror('rpc_create_watchonly_wallet failed!', e); 242 | return false; 243 | } 244 | } 245 | const importError = await rpc_importdescriptors( 246 | this.configService, 247 | this.wallet.name, 248 | `addr(${this.getAddress()})`, 249 | ); 250 | 251 | if (importError instanceof Error) { 252 | logerror('rpc_importdescriptors failed!', importError); 253 | return false; 254 | } 255 | 256 | return true; 257 | } 258 | 259 | foundWallet(): string | null { 260 | const dataDir = this.configService.getDataDir(); 261 | const walletFile = join(dataDir, 'wallet.json'); 262 | let walletString = null; 263 | 264 | try { 265 | walletString = readFileSync(walletFile).toString(); 266 | JSON.parse(walletString); 267 | return walletFile; 268 | } catch (error) {} 269 | 270 | return null; 271 | } 272 | 273 | signTx(tx: btc.Transaction) { 274 | // unlock fee inputs 275 | 276 | const privateKey = this.getPrivateKey(); 277 | const hashData = btc.crypto.Hash.sha256ripemd160( 278 | privateKey.publicKey.toBuffer(), 279 | ); 280 | 281 | for (let i = 0; i < tx.inputs.length; i++) { 282 | const input = tx.inputs[i]; 283 | if (input.output.script.isWitnessPublicKeyHashOut()) { 284 | const signatures = input.getSignatures( 285 | tx, 286 | privateKey, 287 | i, 288 | undefined, 289 | hashData, 290 | undefined, 291 | undefined, 292 | ); 293 | 294 | tx.applySignature(signatures[0]); 295 | } else if (input.output.script.isTaproot() && !input.hasWitnesses()) { 296 | const signatures = input.getSignatures( 297 | tx, 298 | privateKey, 299 | i, 300 | btc.crypto.Signature.SIGHASH_ALL, 301 | hashData, 302 | undefined, 303 | undefined, 304 | ); 305 | 306 | tx.applySignature(signatures[0]); 307 | } 308 | } 309 | } 310 | } 311 | 312 | function derivePrivateKey( 313 | mnemonic: string, 314 | path: string, 315 | network: btc.Network, 316 | ): btc.PrivateKey { 317 | const seed = bip39.mnemonicToSeedSync(mnemonic); 318 | const mainnet = { 319 | messagePrefix: '\x18Bitcoin Signed Message:\n', 320 | bech32: 'bc', 321 | bip32: { 322 | public: 0x0488b21e, 323 | private: 0x0488ade4, 324 | }, 325 | pubKeyHash: 0x00, 326 | scriptHash: 0x05, 327 | wif: 0x80, 328 | }; 329 | const testnet = { 330 | messagePrefix: '\x18Bitcoin Signed Message:\n', 331 | bech32: 'tb', 332 | bip32: { 333 | public: 0x043587cf, 334 | private: 0x04358394, 335 | }, 336 | pubKeyHash: 0x6f, 337 | scriptHash: 0xc4, 338 | wif: 0xef, 339 | }; 340 | 341 | const root = bip32.fromSeed( 342 | seed, 343 | network === btc.Networks.mainnet ? mainnet : testnet, 344 | ); 345 | const wif = root.derivePath(path).toWIF(); 346 | return new btc.PrivateKey(wif, network); 347 | } 348 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | }, 21 | "include": ["./src/**/*"] 22 | } 23 | --------------------------------------------------------------------------------