diff --git a/sdk/package.json b/sdk/package.json index 115ca072..e7e9b678 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -20,7 +20,9 @@ "@types/mocha": "^10.0.2", "@types/node": "^20.8.3", "chai": "^4.3.10", + "ecpair": "^2.1.0", "mocha": "^10.2.0", + "tiny-secp256k1": "^2.2.3", "ts-mocha": "^10.0.0", "ts-node-dev": "^2.0.0", "typescript": "^5.2.2" diff --git a/sdk/src/ordinals/commit.ts b/sdk/src/ordinals/commit.ts new file mode 100644 index 00000000..5118aeb8 --- /dev/null +++ b/sdk/src/ordinals/commit.ts @@ -0,0 +1,97 @@ +import * as bitcoinjsLib from "bitcoinjs-lib"; + +const encoder = new TextEncoder(); + +function toXOnly(pubkey: Buffer) { + return pubkey.subarray(1, 33); +} + +interface Inscription { + contentType: Buffer; + content: Buffer; +} + +/** + * Create a basic text inscription. + */ +export function createTextInscription(text: string): Inscription { + const contentType = Buffer.from(encoder.encode("text/plain;charset=utf-8")); + const content = Buffer.from(encoder.encode(text)); + return { contentType, content }; +} + +function createInscriptionScript(xOnlyPublicKey: Buffer, inscription: Inscription) { + const protocolId = Buffer.from(encoder.encode("ord")); + return [ + xOnlyPublicKey, + bitcoinjsLib.opcodes.OP_CHECKSIG, + bitcoinjsLib.opcodes.OP_0, + bitcoinjsLib.opcodes.OP_IF, + protocolId, + 1, + 1, + inscription.contentType, + bitcoinjsLib.opcodes.OP_0, + inscription.content, + bitcoinjsLib.opcodes.OP_ENDIF, + ]; +} + +export interface CommitTxData { + script: (number | Buffer)[]; + scriptTaproot: bitcoinjsLib.payments.Payment; + outputScript: Buffer; + tapLeafScript: { + leafVersion: number; + script: Buffer; + controlBlock: Buffer; + } +} + +/** + * Create the commit tx of the input public key and inscription data. + * @dev Requires caller to initialize ECC lib. + */ +export function createCommitTxData( + network: bitcoinjsLib.Network, + publicKey: Buffer, + inscription: Inscription +): CommitTxData { + const xOnlyPublicKey = toXOnly(publicKey); + const script = createInscriptionScript(xOnlyPublicKey, inscription); + + const outputScript = bitcoinjsLib.script.compile(script); + + const scriptTree = { + output: outputScript, + redeemVersion: 192, // 0xc0 + }; + + const scriptTaproot = bitcoinjsLib.payments.p2tr({ + internalPubkey: xOnlyPublicKey, + scriptTree, + redeem: scriptTree, + network, + }); + + const cblock = scriptTaproot.witness?.[scriptTaproot.witness.length - 1]; + + const tapLeafScript = { + leafVersion: scriptTaproot.redeemVersion!, + script: outputScript, + controlBlock: cblock, + }; + + return { + script, + scriptTaproot, + outputScript, + tapLeafScript, + }; +} + +export interface CommitTxResult { + txId: string; + sendUtxoIndex: number; + sendAmount: number; +} diff --git a/sdk/src/ordinals/index.ts b/sdk/src/ordinals/index.ts new file mode 100644 index 00000000..1d4133be --- /dev/null +++ b/sdk/src/ordinals/index.ts @@ -0,0 +1,99 @@ +import * as bitcoinjsLib from "bitcoinjs-lib"; + +import { DummySigner, RemoteSigner } from "./signer"; +import { CommitTxData, createCommitTxData, createTextInscription } from "./commit"; +import { createRevealTx, customFinalizer, signRevealTx } from "./reveal"; + +export { RemoteSigner }; + +/** + * Estimate the virtual size of a 1 input 1 output reveal tx. + */ +function estimateTxSize( + network: bitcoinjsLib.Network, + publicKey: Buffer, + commitTxData: CommitTxData, + toAddress: string, + amount: number, +) { + const psbt = new bitcoinjsLib.Psbt({ network }); + + const { scriptTaproot, tapLeafScript } = commitTxData; + psbt.addInput({ + hash: Buffer.alloc(32, 0), + index: 0, + witnessUtxo: { + value: amount, + script: scriptTaproot.output!, + }, + tapLeafScript: [tapLeafScript], + }); + + psbt.addOutput({ + value: amount, + address: toAddress, + }); + + psbt.signInput(0, new DummySigner(publicKey)); + psbt.finalizeInput(0, customFinalizer(commitTxData)); + + const tx = psbt.extractTransaction(); + return tx.virtualSize(); +} + +/** + * Inscribe some text data on Bitcoin using the remote signer. + * + * @param signer - Implementation to interact with Bitcoin and sign the PSBT. + * @param toAddress - The address to receive the inscription. + * @param feeRate - Fee rate of the Bitcoin network (satoshi / byte). + * @param text - Data to inscribe in the witness of the reveal transaction. + * @param postage - Amount of postage to include in the inscription. + * @returns Promise which resolves to the reveal transaction. + */ +export async function inscribeText( + signer: RemoteSigner, + toAddress: string, + feeRate: number, + text: string, + postage = 10000, +) { + const bitcoinNetwork = await signer.getNetwork(); + const publicKey = Buffer.from(await signer.getPublicKey(), "hex"); + + const inscription = createTextInscription(text); + const commitTxData = createCommitTxData(bitcoinNetwork, publicKey, inscription); + + const revealTxSize = estimateTxSize(bitcoinNetwork, publicKey, commitTxData, toAddress, postage); + + // https://github.com/ordinals/ord/blob/ea1c7c8f73e1c30df547000ac7ccd82051cb60af/src/subcommand/wallet/inscribe/batch.rs#L501 + const revealFee = revealTxSize * feeRate; + // https://github.com/ordinals/ord/blob/ea1c7c8f73e1c30df547000ac7ccd82051cb60af/src/subcommand/wallet/inscribe/batch.rs#L327 + const commitTxAmount = revealFee + postage; + + const commitAddress = commitTxData.scriptTaproot.address!; + const commitTxId = await signer.sendToAddress(commitAddress, commitTxAmount); + const commitUtxoIndex = await signer.getUtxoIndex(commitAddress, commitTxId); + + const commitTxResult = { + txId: commitTxId, + sendUtxoIndex: commitUtxoIndex, + sendAmount: commitTxAmount, + }; + + const revealPsbt = createRevealTx( + bitcoinNetwork, + commitTxData, + commitTxResult, + toAddress, + postage, + ); + + const revealTx = await signRevealTx( + signer, + commitTxData, + revealPsbt + ); + + return revealTx; +} diff --git a/sdk/src/ordinals/reveal.ts b/sdk/src/ordinals/reveal.ts new file mode 100644 index 00000000..65c6a34a --- /dev/null +++ b/sdk/src/ordinals/reveal.ts @@ -0,0 +1,67 @@ +import * as bitcoinjsLib from "bitcoinjs-lib"; +import * as psbtUtils from "bitcoinjs-lib/src/psbt/psbtutils"; + +import { RemoteSigner } from "./signer"; +import { CommitTxData, CommitTxResult } from "./commit"; + +const { witnessStackToScriptWitness } = psbtUtils; + +/** + * Create the reveal tx which spends the commit tx. + */ +export function createRevealTx( + network: bitcoinjsLib.Network, + commitTxData: CommitTxData, + commitTxResult: CommitTxResult, + toAddress: string, + amount: number, +) { + const { scriptTaproot, tapLeafScript } = commitTxData; + + const psbt = new bitcoinjsLib.Psbt({ network }); + + psbt.addInput({ + hash: commitTxResult.txId, + index: commitTxResult.sendUtxoIndex, + witnessUtxo: { + value: commitTxResult.sendAmount, + script: scriptTaproot.output!, + }, + tapLeafScript: [tapLeafScript], + }); + + psbt.addOutput({ + value: amount, + address: toAddress, + }); + + return psbt; +} + +export const customFinalizer = (commitTxData: CommitTxData) => { + const { outputScript, tapLeafScript } = commitTxData; + + return (inputIndex: number, input: any) => { + const witness = [input.tapScriptSig[inputIndex].signature] + .concat(outputScript) + .concat(tapLeafScript.controlBlock); + + return { + finalScriptWitness: witnessStackToScriptWitness(witness), + }; + }; +} + +export async function signRevealTx( + signer: RemoteSigner, + commitTxData: CommitTxData, + psbt: bitcoinjsLib.Psbt +) { + // reveal should only have one input + psbt = await signer.signPsbt(0, psbt); + + // we have to construct our witness script in a custom finalizer + psbt.finalizeInput(0, customFinalizer(commitTxData)); + + return psbt.extractTransaction(); +} \ No newline at end of file diff --git a/sdk/src/ordinals/signer.ts b/sdk/src/ordinals/signer.ts new file mode 100644 index 00000000..40e5a87c --- /dev/null +++ b/sdk/src/ordinals/signer.ts @@ -0,0 +1,58 @@ +import { Network, Psbt, Signer } from "bitcoinjs-lib"; + +/** + * Dummy signer implementation used to estimate tx fees. + */ +export class DummySigner implements Signer { + publicKey: Buffer; + constructor(publicKey: Buffer) { + this.publicKey = publicKey; + } + sign(_hash: Buffer, _lowR?: boolean | undefined): Buffer { + // https://github.com/bitcoin/bitcoin/blob/607d5a46aa0f5053d8643a3e2c31a69bfdeb6e9f/src/script/sign.cpp#L611 + return Buffer.from("304502210100000000000000000000000000000000000000000000000000000000000000000220010000000000000000000000000000000000000000000000000000000000000001", "hex"); + } + signSchnorr(hash: Buffer): Buffer { + // https://github.com/bitcoin/bitcoin/blob/607d5a46aa0f5053d8643a3e2c31a69bfdeb6e9f/src/script/sign.cpp#L626 + return Buffer.alloc(64, 0); + } +} + +export interface RemoteSigner { + /** + * Get the configured Bitcoin network. + * + * @returns {Promise} A promise that resolves to the current network. + */ + getNetwork(): Promise; + /** + * Get the configured public key of the signer. + * + * @returns {Promise} A promise that resolves to the hex encoded public key. + */ + getPublicKey(): Promise; + /** + * Send an amount of Satoshis to the recipient. + * + * @param {string} toAddress - The address of the recipient. + * @param {number} amount - The Satoshis the recipient should receive. + * @returns {Promise} A promise that resolves to the transaction ID. + */ + sendToAddress(toAddress: string, amount: number): Promise; + /** + * Get the index of a UTXO in a transaction based on the recipient address. + * + * @param {string} toAddress - The address of the recipient. + * @param {string} txId - The transaction ID to check. + * @returns {Promise} A promise that resolves to the UTXO index. + */ + getUtxoIndex(toAddress: string, txId: string): Promise; + /** + * Sign the PSBT at the specified input index. + * + * @param {number} inputIndex - The input index to sign for. + * @param {Psbt} psbt - The PSBT containing that input. + * @returns {Promise} A promise that resolves to the signed PSBT. + */ + signPsbt(inputIndex: number, psbt: Psbt): Promise; +}; diff --git a/sdk/src/relay.ts b/sdk/src/relay.ts index 9de303ad..40f716bf 100644 --- a/sdk/src/relay.ts +++ b/sdk/src/relay.ts @@ -57,7 +57,6 @@ export async function getBitcoinTxInfo( electrsClient: ElectrsClient, txId: string, forWitness?: boolean, - forWitness?: boolean, ): Promise { const txHex = await electrsClient.getTransactionHex(txId); const tx = Transaction.fromHex(txHex); @@ -125,7 +124,7 @@ export async function getBitcoinTxProof( bitcoinHeaders: bitcoinHeaders, } } - + /** * Retrieves Bitcoin block headers using an Electrs client. * diff --git a/sdk/test/ordinals.test.ts b/sdk/test/ordinals.test.ts new file mode 100644 index 00000000..efd758d6 --- /dev/null +++ b/sdk/test/ordinals.test.ts @@ -0,0 +1,51 @@ +import { assert } from "chai"; +import * as ecc from "tiny-secp256k1"; +import * as ECPairFactory from "ecpair"; +import { RemoteSigner, inscribeText } from "../src/ordinals"; +import { Network, Psbt, Transaction, address, initEccLib } from "bitcoinjs-lib"; +import { bitcoin } from "bitcoinjs-lib/src/networks"; + +const ECPair = ECPairFactory.default(ecc); +initEccLib(ecc); + +class StaticSigner implements RemoteSigner { + keyPair: ECPairFactory.ECPairInterface; + + constructor(secret: string) { + const privateKey = Buffer.from(secret, "hex"); + this.keyPair = ECPair.fromPrivateKey(privateKey); + } + + async getNetwork(): Promise { + return bitcoin; + } + + async getPublicKey(): Promise { + return this.keyPair.publicKey.toString("hex"); + } + + async sendToAddress(toAddress: string, amount: number): Promise { + const tx = new Transaction(); + tx.addOutput(address.toOutputScript(toAddress), amount); + return tx.getId(); + } + + async getUtxoIndex(_toAddress: string, _txId: string): Promise { + return 0; + } + + async signPsbt(inputIndex: number, psbt: Psbt): Promise { + psbt.signInput(inputIndex, this.keyPair); + return psbt; + } +} + +describe("Ordinal Tests", () => { + it("should inscribe text", async () => { + const secret = "fc7458de3d5616e7803fdc81d688b9642641be32fee74c4558ce680cac3d4111"; + const signer = new StaticSigner(secret); + const toAddress = "bc1pxaneaf3w4d27hl2y93fuft2xk6m4u3wc4rafevc6slgd7f5tq2dqyfgy06"; + const tx = await inscribeText(signer, toAddress, 1, "Hello World!", 546); + assert(tx.getId() == "5fbafffdccaecb857c2a405c4e9bb54094b10c2c3cc548222bb57d25a3f69b82"); + }); +}); diff --git a/sdk/test/utils.test.ts b/sdk/test/utils.test.ts index 8eda2139..6c52b7c8 100644 --- a/sdk/test/utils.test.ts +++ b/sdk/test/utils.test.ts @@ -18,5 +18,5 @@ describe("Utils Tests", () => { proof: '6034ddf453f5dd20de449b29b1221dede67ccae56f00528e0767e2ab506db31c4d2946e88f7efa3e94bb17bbd10f3f44172b59c48f2eb6bd7f67a88d149373ee4082c8b474ccf00906a1e61694fdf0b717790ac3bdf850b36afb8df107aca93b7c3c4f91ddf49c7f74244336c5833377d40760ae09dd1fba83063ace480f94cca3920a489b23f9133fc84d7987d990acc7c2569a81b547a5f65385856d90100e84878b4f305a3909a9420293cdc741109864c9338ea326449a7a303b227f2b10490bc4343355e1a391f51c42918a894c2980012cca5ffd4b56a6702abd98497802de83f5889b2ad5bd157762a58505948f32f42b9fa886c93bf30fef6144a64666843a28ef13184f9e7ac3c34b5741f58c8895a0167f496e0157e7d0a97f4041f97b8df4d8aee81d20d0d062ed3ee0f9b0afb196bdf5373712883cacdfd8349b739c0e6e41d650d05727ea5faec197bfa563d19b0150fba718ba1981aea9ef90', root: '7cee5e99c8f0fc25fb115b7d7d00befca61f59a8544adaf3980f52132baf61ae' }); - }).timeout(5000); + }).timeout(20000); });