From 32770c2a99963979b0e1bd686543d8052071eae6 Mon Sep 17 00:00:00 2001 From: Nishant Ghodke <64554492+iamcrazycoder@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:34:55 +0530 Subject: [PATCH] feat(sdk)!: introduce royalty payments in instant-trade flow (#66) * feat: add creator royalty as per OIP-6 ref: https://www.oips.io/oip-06-ordinals-royalty-standard * refactor: set max decimals to 5 * refactor: make royalty optional * fix: update property name * feat: bind royalty payment to seller PSBT * feat: support multiple injectable outputs * fix: check if pct is number * refactor: update publish-collection example * feat: add royalty class member * refactor: rename inscriberMode to autoAdjustment * fix: bind index to collection txid * fix: prevent injectable output from replacing existing output * fix: retrieve more UTXOs only when pre-fetched UTXOs are not enough * refactor!: cleanup & class member visibility change also, replace direct assignment w/ passing param in constructor * refactor: update publish-collection example * refactor: update decimals for royalty pct to 8 * refactor: pay no royalty if amount below dust --- examples/node/collections.js | 17 ++-- packages/sdk/src/fee/FeeEstimator.ts | 10 +-- packages/sdk/src/inscription/collection.ts | 34 +++++-- .../src/instant-trade/InstantTradeBuilder.ts | 11 ++- .../InstantTradeBuyerTxBuilder.ts | 33 ++++--- .../InstantTradeSellerTxBuilder.ts | 46 ++++++++-- packages/sdk/src/transactions/Inscriber.ts | 50 +++-------- packages/sdk/src/transactions/PSBTBuilder.ts | 88 +++++++++++-------- 8 files changed, 176 insertions(+), 113 deletions(-) diff --git a/examples/node/collections.js b/examples/node/collections.js index cc442291..95f65e2a 100644 --- a/examples/node/collections.js +++ b/examples/node/collections.js @@ -17,19 +17,20 @@ const publisherWallet = new Ordit({ // set default address types for both wallets userWallet.setDefaultAddress("taproot"); -publisherWallet.setDefaultAddress("nested-segwit"); +publisherWallet.setDefaultAddress("taproot"); async function publish() { const getPublisherLegacyAddress = () => { publisherWallet.setDefaultAddress("legacy") const legacyAddress = publisherWallet.selectedAddress - publisherWallet.setDefaultAddress("nested-segwit") // switch back to default + publisherWallet.setDefaultAddress("taproot") // switch back to default return legacyAddress } //publish const transaction = await publishCollection({ + address: publisherWallet.selectedAddress, network, feeRate: 2, title: "Collection Name", @@ -40,17 +41,16 @@ async function publish() { email: "your-email@example.com", name: "Your Name" }, + royalty: { + address: publisherWallet.selectedAddress, + pct: 0.05 + }, publishers: [getPublisherLegacyAddress()], inscriptions: [ { iid: "el-01", lim: 10, sri: "sha256-Ujac9y464/qlFmtfLDxORaUtIDH8wrHgv8L9bpPeb28=" - }, - { - iid: "el-02", - lim: 2, - sri: "sha256-zjQXDuk++5sICrObmfWqAM5EibidXd2emZoUcU2l5Pg=" } ], url: "https://example.com", @@ -58,7 +58,7 @@ async function publish() { destination: publisherWallet.selectedAddress, changeAddress: publisherWallet.selectedAddress, postage: 1000, - mediaContent: 'Collection Name', // this will be inscribed on-chain as primary content + mediaContent: '5% Royalty Collection', // this will be inscribed on-chain as primary content mediaType: "text/plain" }); @@ -88,6 +88,7 @@ async function mint() { // publish const transaction = await mintFromCollection({ + address: userWallet.selectedAddress, network, collectionOutpoint: collectionId, inscriptionIid: "el-01", diff --git a/packages/sdk/src/fee/FeeEstimator.ts b/packages/sdk/src/fee/FeeEstimator.ts index 1ef95b06..fa0b6a49 100644 --- a/packages/sdk/src/fee/FeeEstimator.ts +++ b/packages/sdk/src/fee/FeeEstimator.ts @@ -6,11 +6,11 @@ import { MAXIMUM_FEE } from "../constants" import { FeeEstimatorOptions } from "./types" export default class FeeEstimator { - feeRate: number - network: Network - psbt!: Psbt - witness?: Buffer[] = [] - fee = 0 + protected feeRate: number + protected network: Network + protected psbt: Psbt + protected witness?: Buffer[] = [] + protected fee = 0 private virtualSize = 0 private weight = 0 diff --git a/packages/sdk/src/inscription/collection.ts b/packages/sdk/src/inscription/collection.ts index 814b6ee8..e6b7e1ea 100644 --- a/packages/sdk/src/inscription/collection.ts +++ b/packages/sdk/src/inscription/collection.ts @@ -7,6 +7,7 @@ export async function publishCollection({ url, slug, creator, + royalty, publishers, inscriptions, ...options @@ -15,15 +16,30 @@ export async function publishCollection({ throw new Error("Invalid inscriptions supplied.") } + if (royalty) { + // 0 = 0%, 10 = 1000% + if (isNaN(royalty.pct) || royalty.pct < 0 || royalty.pct > 10) { + throw new Error("Invalid royalty %") + } + + royalty.pct = +new Intl.NumberFormat("en", { + maximumFractionDigits: 8, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + roundingMode: "trunc" + }).format(royalty.pct) + } + const collectionMeta = { p: "vord", // protocol v: 1, // version ty: "col", - title: title, + title, desc: description, - url: url, - slug: slug, - creator: creator, + url, + slug, + creator, + royalty, publ: publishers, insc: inscriptions } @@ -110,6 +126,7 @@ function validateInscriptions(inscriptions: CollectionInscription[] = []) { } export type PublishCollectionOptions = Pick & { + address: string feeRate: number postage: number mediaType: string @@ -127,9 +144,13 @@ export type PublishCollectionOptions = Pick & { email?: string address: string } + royalty?: { + address: string + pct: number + } network: Network publicKey: string - outs?: Outputs + outputs?: Outputs encodeMetadata?: boolean enableRBF?: boolean } @@ -141,6 +162,7 @@ export type CollectionInscription = { } export type MintFromCollectionOptions = Pick & { + address: string feeRate: number postage: number mediaType: string @@ -154,7 +176,7 @@ export type MintFromCollectionOptions = Pick & { signature: string network: Network publicKey: string - outs?: Outputs + outputs?: Outputs traits?: any encodeMetadata?: boolean enableRBF?: boolean diff --git a/packages/sdk/src/instant-trade/InstantTradeBuilder.ts b/packages/sdk/src/instant-trade/InstantTradeBuilder.ts index 0caf4f7c..40e459ca 100644 --- a/packages/sdk/src/instant-trade/InstantTradeBuilder.ts +++ b/packages/sdk/src/instant-trade/InstantTradeBuilder.ts @@ -2,7 +2,8 @@ import { OrditApi } from ".." import { MINIMUM_AMOUNT_IN_SATS } from "../constants" import { PSBTBuilder, PSBTBuilderOptions } from "../transactions/PSBTBuilder" -export interface InstantTradeBuilderArgOptions extends Pick { +export interface InstantTradeBuilderArgOptions + extends Pick { inscriptionOutpoint?: string } @@ -10,14 +11,16 @@ export default class InstantTradeBuilder extends PSBTBuilder { protected inscriptionOutpoint?: string protected price = 0 protected postage = 0 + protected royalty = 0 - constructor({ address, network, publicKey, inscriptionOutpoint }: InstantTradeBuilderArgOptions) { + constructor({ address, network, publicKey, inscriptionOutpoint, autoAdjustment }: InstantTradeBuilderArgOptions) { super({ address, feeRate: 0, network, publicKey, outputs: [], + autoAdjustment, instantTradeMode: true }) @@ -30,6 +33,10 @@ export default class InstantTradeBuilder extends PSBTBuilder { this.price = parseInt(value.toString()) } + setRoyalty(value: number) { + this.royalty = value + } + protected async verifyAndFindInscriptionUTXO(address?: string) { if (!this.inscriptionOutpoint) { throw new Error("set inscription outpoint to the class") diff --git a/packages/sdk/src/instant-trade/InstantTradeBuyerTxBuilder.ts b/packages/sdk/src/instant-trade/InstantTradeBuyerTxBuilder.ts index 82b7872b..a9725d09 100644 --- a/packages/sdk/src/instant-trade/InstantTradeBuyerTxBuilder.ts +++ b/packages/sdk/src/instant-trade/InstantTradeBuyerTxBuilder.ts @@ -15,7 +15,6 @@ import { Output } from "../transactions/types" import InstantTradeBuilder, { InstantTradeBuilderArgOptions } from "./InstantTradeBuilder" interface InstantTradeBuyerTxBuilderArgOptions extends InstantTradeBuilderArgOptions { - feeRate: number sellerPSBT: string receiveAddress?: string } @@ -36,10 +35,10 @@ export default class InstantTradeBuyerTxBuilder extends InstantTradeBuilder { super({ address, network, - publicKey + publicKey, + feeRate }) - this.feeRate = feeRate this.receiveAddress = receiveAddress this.decodeSellerPSBT(sellerPSBT) } @@ -68,6 +67,11 @@ export default class InstantTradeBuyerTxBuilder extends InstantTradeBuilder { this.setPrice((this.sellerPSBT.data.globalMap.unsignedTx as any).tx.outs[0].value - this.postage) } + private decodeRoyalty() { + const royaltyOutput = (this.sellerPSBT.data.globalMap.unsignedTx as any).tx.outs[1] + royaltyOutput && this.setRoyalty(royaltyOutput.value) + } + private bindRefundableOutput() { this.outputs = [ { @@ -98,17 +102,17 @@ export default class InstantTradeBuyerTxBuilder extends InstantTradeBuilder { sats: this.sellerPSBT.data.inputs[0].witnessUtxo?.value, injectionIndex: INSTANT_BUY_SELLER_INPUT_INDEX } - ] as unknown as InjectableInput[] - - //outputs - this.injectableOutputs = [ - { - standardOutput: this.sellerPSBT.data.outputs[0], - txOutput: (this.sellerPSBT.data.globalMap.unsignedTx as any).tx.outs[0], - sats: (this.sellerPSBT.data.globalMap.unsignedTx as any).tx.outs[0].value, - injectionIndex: INSTANT_BUY_SELLER_INPUT_INDEX - } - ] as InjectableOutput[] + ] as InjectableInput[] + + // outputs + this.injectableOutputs = this.sellerPSBT.data.outputs.map((standardOutput, index) => { + return { + standardOutput, + txOutput: (this.sellerPSBT.data.globalMap.unsignedTx as any).tx.outs[index], + sats: (this.sellerPSBT.data.globalMap.unsignedTx as any).tx.outs[index].value, + injectionIndex: INSTANT_BUY_SELLER_INPUT_INDEX + index + } as InjectableOutput + }) } private async findUTXOs() { @@ -155,6 +159,7 @@ export default class InstantTradeBuyerTxBuilder extends InstantTradeBuilder { } this.decodePrice() + this.decodeRoyalty() this.bindRefundableOutput() this.bindInscriptionOutput() this.mergePSBTs() diff --git a/packages/sdk/src/instant-trade/InstantTradeSellerTxBuilder.ts b/packages/sdk/src/instant-trade/InstantTradeSellerTxBuilder.ts index 6471ff11..7c5f19c6 100644 --- a/packages/sdk/src/instant-trade/InstantTradeSellerTxBuilder.ts +++ b/packages/sdk/src/instant-trade/InstantTradeSellerTxBuilder.ts @@ -1,6 +1,7 @@ import * as bitcoin from "bitcoinjs-lib" -import { processInput } from ".." +import { OrditApi, processInput } from ".." +import { MINIMUM_AMOUNT_IN_SATS } from "../constants" import { UTXO } from "../transactions/types" import InstantTradeBuilder, { InstantTradeBuilderArgOptions } from "./InstantTradeBuilder" @@ -23,11 +24,12 @@ export default class InstantTradeSellerTxBuilder extends InstantTradeBuilder { address, network, publicKey, - inscriptionOutpoint + inscriptionOutpoint, + autoAdjustment: false, // Prevents PSBTBuilder from adding additional input and change output + feeRate: 0 // seller in instant-trade does not pay network fee }) this.receiveAddress = receiveAddress - this.feeRate = 0 // seller in instant-trade does not pay network fee } private async generatSellerInputs() { @@ -45,15 +47,47 @@ export default class InstantTradeSellerTxBuilder extends InstantTradeBuilder { this.inputs = [input] } - private generateSellerOutputs() { + private async generateSellerOutputs() { + const royalty = await this.calculateRoyalty() this.outputs = [{ address: this.receiveAddress || this.address, value: this.price + this.postage }] + + if (royalty && royalty.amount >= MINIMUM_AMOUNT_IN_SATS) { + this.outputs.push({ + address: royalty.address, // creator address + value: royalty.amount // royalty in sats to be paid to original creator + }) + } + } + + private async calculateRoyalty() { + if (!this.utxo?.inscriptions?.length || !this.utxo?.inscriptions[0]?.meta?.col) { + return + } + + const collection = await OrditApi.fetchInscription({ + id: `${this.utxo.inscriptions[0].meta.col}i0`, + network: this.network + }) + const royalty = collection.meta?.royalty + if (!royalty || !royalty.address || !royalty.pct) { + return + } + const amount = Math.ceil(royalty.pct * this.price) + + return { + address: royalty.address as string, + amount: amount >= MINIMUM_AMOUNT_IN_SATS ? amount : 0 + } } async build() { - this.utxo = await this.verifyAndFindInscriptionUTXO() + if (isNaN(this.price) || this.price < MINIMUM_AMOUNT_IN_SATS) { + throw new Error("Invalid price") + } + this.utxo = await this.verifyAndFindInscriptionUTXO() await this.generatSellerInputs() - this.generateSellerOutputs() + await this.generateSellerOutputs() await this.prepare() } diff --git a/packages/sdk/src/transactions/Inscriber.ts b/packages/sdk/src/transactions/Inscriber.ts index 6943bc55..d2ea7baf 100644 --- a/packages/sdk/src/transactions/Inscriber.ts +++ b/packages/sdk/src/transactions/Inscriber.ts @@ -6,7 +6,6 @@ import { buildWitnessScript, createTransaction, encodeObject, - getAddressesFromPublicKey, getDummyP2TRInput, getNetwork, GetWalletOptions, @@ -20,21 +19,12 @@ import { UTXOLimited } from "./types" bitcoin.initEccLib(ecc) export class Inscriber extends PSBTBuilder { - network: Network - - mediaType: string - mediaContent: string - meta?: NestedObject - postage: number - - address: string - publicKey: string - destinationAddress: string - changeAddress: string + protected mediaType: string + protected mediaContent: string + protected meta?: NestedObject + protected postage: number private ready = false - - private xKey!: string private commitAddress: string | null = null private payment: bitcoin.payments.Payment | null = null private suitableUnspent: UTXOLimited | null = null @@ -51,50 +41,37 @@ export class Inscriber extends PSBTBuilder { constructor({ network, + address, + changeAddress, + publicKey, feeRate, postage, mediaContent, mediaType, - publicKey, outputs = [], encodeMetadata = false, - changeAddress, - destination, safeMode, meta }: InscriberArgOptions) { - const { xkey, address } = getAddressesFromPublicKey(publicKey, network, "p2tr")[0] super({ - address: address!, + address, + changeAddress, feeRate, network, publicKey, outputs, - inscriberMode: true + autoAdjustment: false }) - if (!publicKey || !changeAddress || !destination || !mediaContent) { + if (!publicKey || !changeAddress || !mediaContent) { throw new Error("Invalid options provided") } - this.publicKey = publicKey - this.feeRate = feeRate this.mediaType = mediaType - this.network = network - this.changeAddress = changeAddress - this.destinationAddress = destination this.mediaContent = mediaContent this.meta = meta this.postage = postage - this.outputs = outputs this.safeMode = !safeMode ? "on" : safeMode this.encodeMetadata = encodeMetadata - - if (!xkey) { - throw new Error("Failed to derive xKey from the provided public key") - } - - this.xKey = xkey - this.address = address! } private getMetadata() { @@ -128,7 +105,7 @@ export class Inscriber extends PSBTBuilder { if (!this.recovery) { this.outputs.push({ - address: this.destinationAddress, + address: this.address, value: this.postage }) } @@ -276,6 +253,8 @@ export class OrdTransaction extends Inscriber { export type InscriberArgOptions = Pick & { network: Network + address: string + publicKey: string feeRate: number postage: number mediaType: string @@ -283,7 +262,6 @@ export type InscriberArgOptions = Pick & { destination: string changeAddress: string meta?: NestedObject - publicKey: string outputs?: Outputs encodeMetadata?: boolean } diff --git a/packages/sdk/src/transactions/PSBTBuilder.ts b/packages/sdk/src/transactions/PSBTBuilder.ts index 50ff1508..a9ff5d8d 100644 --- a/packages/sdk/src/transactions/PSBTBuilder.ts +++ b/packages/sdk/src/transactions/PSBTBuilder.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-extra-semi */ import { networks, Psbt } from "bitcoinjs-lib" import reverseBuffer from "buffer-reverse" @@ -7,7 +8,8 @@ import { getNetwork, InputsToSign, INSTANT_BUY_SELLER_INPUT_INDEX, - OrditApi + OrditApi, + toXOnly } from ".." import { Network } from "../config/types" import { MINIMUM_AMOUNT_IN_SATS } from "../constants" @@ -22,7 +24,7 @@ export interface PSBTBuilderOptions { network: Network outputs: Output[] publicKey: string - inscriberMode?: boolean + autoAdjustment?: boolean instantTradeMode?: boolean } @@ -41,27 +43,26 @@ export interface InjectableOutput { } export class PSBTBuilder extends FeeEstimator { - private nativeNetwork: networks.Network - private inscriberMode: boolean + protected address: string + protected changeAddress?: string + protected changeAmount = 0 + protected changeOutputIndex = -1 + protected injectableInputs: InjectableInput[] = [] + protected injectableOutputs: InjectableOutput[] = [] + protected inputAmount = 0 + protected inputs: InputType[] = [] + protected outputAmount = 0 + protected outputs: Output[] = [] + protected psbt: Psbt + protected publicKey: string + protected rbf = true + protected utxos: UTXOLimited[] = [] + protected usedUTXOs: string[] = [] + + private autoAdjustment: boolean private instantTradeMode: boolean - - address: string - changeAddress?: string - changeAmount = 0 - changeOutputIndex = -1 - inputs: InputType[] = [] - injectableInputs: InjectableInput[] = [] - injectableOutputs: InjectableOutput[] = [] - inputAmount = 0 - outputs: Output[] = [] - outputAmount = 0 - network: Network - noMoreUTXOS = false - psbt: Psbt - publicKey: string - rbf = true - utxos: UTXOLimited[] = [] - usedUTXOs: string[] = [] + private nativeNetwork: networks.Network + private noMoreUTXOS = false constructor({ address, @@ -70,7 +71,7 @@ export class PSBTBuilder extends FeeEstimator { network, publicKey, outputs, - inscriberMode = false, + autoAdjustment = true, instantTradeMode = false }: PSBTBuilderOptions) { super({ @@ -79,11 +80,11 @@ export class PSBTBuilder extends FeeEstimator { }) this.address = address this.changeAddress = changeAddress - this.network = network this.outputs = outputs this.nativeNetwork = getNetwork(network) this.publicKey = publicKey - this.inscriberMode = inscriberMode + + this.autoAdjustment = autoAdjustment this.instantTradeMode = instantTradeMode this.psbt = new Psbt({ network: this.nativeNetwork }) @@ -111,6 +112,10 @@ export class PSBTBuilder extends FeeEstimator { this.addInputs() } + get xKey() { + return toXOnly(Buffer.from(this.publicKey, "hex")).toString("hex") + } + get inputsToSign() { return this.psbt.txInputs.reduce( (acc, _, index) => { @@ -137,17 +142,21 @@ export class PSBTBuilder extends FeeEstimator { } private injectInput(injectable: InjectableInput) { - // TODO: add type - // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(this.psbt.data.globalMap.unsignedTx as any).tx.ins[injectable.injectionIndex] = injectable.txInput this.psbt.data.inputs[injectable.injectionIndex] = injectable.standardInput } private injectOutput(injectable: InjectableOutput) { - // TODO: add type - // eslint-disable-next-line @typescript-eslint/no-extra-semi - ;(this.psbt.data.globalMap.unsignedTx as any).tx.outs[injectable.injectionIndex] = injectable.txOutput - this.psbt.data.outputs[injectable.injectionIndex] = injectable.standardOutput + let potentialIndex = injectable.injectionIndex + + do { + const isReserved = !!(this.psbt.data.globalMap.unsignedTx as any).tx.outs[potentialIndex] + if (!isReserved) { + ;(this.psbt.data.globalMap.unsignedTx as any).tx.outs[potentialIndex] = injectable.txOutput + this.psbt.data.outputs[potentialIndex] = injectable.standardOutput + break + } + } while (potentialIndex++) } private async addInputs() { @@ -177,6 +186,7 @@ export class PSBTBuilder extends FeeEstimator { this.injectableInputs.forEach((injectable) => { if (injectedIndexes.includes(injectable.injectionIndex)) return this.injectInput(injectable) + injectedIndexes.push(injectable.injectionIndex) }) } @@ -206,6 +216,7 @@ export class PSBTBuilder extends FeeEstimator { this.injectableOutputs.forEach((injectable) => { if (injectedIndexes.includes(injectable.injectionIndex)) return this.injectOutput(injectable) + injectedIndexes.push(injectable.injectionIndex) }) } @@ -258,7 +269,7 @@ export class PSBTBuilder extends FeeEstimator { } private async calculateChangeAmount() { - if (this.inscriberMode) return + if (!this.autoAdjustment) return this.changeAmount = Math.floor(this.inputAmount - this.outputAmount - this.fee) await this.addChangeOutput() @@ -273,15 +284,21 @@ export class PSBTBuilder extends FeeEstimator { } } + private getRetrievedUTXOsValue() { + return this.utxos.reduce((acc, utxo) => (acc += utxo.sats), 0) + } + private getReservedUTXOs() { return this.utxos.map((utxo) => generateTxUniqueIdentifier(utxo.txid, utxo.n)) } private async retrieveUTXOs(address?: string, amount?: number) { - if (this.inscriberMode && !address) return + if (!this.autoAdjustment && !address) return amount = amount && amount > 0 ? amount : this.changeAmount < 0 ? this.changeAmount * -1 : this.outputAmount + if (this.getRetrievedUTXOsValue() > amount) return + const utxos = await OrditApi.fetchSpendables({ address: address || this.address, value: convertSatoshisToBTC(amount), @@ -303,7 +320,7 @@ export class PSBTBuilder extends FeeEstimator { } private async prepareInputs() { - if (this.inscriberMode) return + if (!this.autoAdjustment) return const promises: Promise[] = [] @@ -344,7 +361,6 @@ export class PSBTBuilder extends FeeEstimator { await this.calculateChangeAmount() await this.process() - await this.calculateChangeAmount() this.calculateOutputAmount() @@ -354,7 +370,7 @@ export class PSBTBuilder extends FeeEstimator { private async process() { this.initPSBT() - this.addInputs() + await this.addInputs() this.addOutputs() this.calculateNetworkFee()