Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sdk): pre-inscriber + better utxo splitting #130

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.0.5",
"bignumber.js": "^9.1.2",
"bip32": "^4.0.0",
"bip322-js": "^1.1.0",
"bip39": "^3.1.0",
"bitcoinjs-lib": "^6.1.5",
"bitcoinjs-message": "^2.2.0",
"bitcoinselect": "github:ordzaar/bitcoinselect",
"bitcoin-address-validation": "^2.2.3",
"buffer-reverse": "^1.0.1",
"cross-fetch": "^3.1.8",
"ecpair": "^2.1.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/modules/JsonRpcDatasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export default class JsonRpcDatasource extends BaseDatasource {
async getUnspents({
address,
type = "spendable",
rarity = ["common"],
rarity = ["common", "uncommon"],
sort = "desc",
limit = 50,
next = null
Expand Down
90 changes: 45 additions & 45 deletions packages/sdk/src/transactions/PSBTBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-extra-semi */
import { networks, Psbt, Transaction } from "bitcoinjs-lib"
import reverseBuffer from "buffer-reverse"

import {
BaseDatasource,
Expand All @@ -25,6 +24,7 @@ export interface PSBTBuilderOptions {
feeRate: number
network: Network
outputs: Output[]
inputs?: InputType[]
publicKey: string
autoAdjustment?: boolean
instantTradeMode?: boolean
Expand Down Expand Up @@ -87,6 +87,7 @@ export class PSBTBuilder extends FeeEstimator {
network,
publicKey,
outputs,
inputs,
autoAdjustment = true,
instantTradeMode = false,
chain = "bitcoin"
Expand All @@ -108,6 +109,7 @@ export class PSBTBuilder extends FeeEstimator {
this.instantTradeMode = instantTradeMode

this.psbt = new Psbt({ network: this.nativeNetwork })
this.inputs = inputs || []
}

toPSBT() {
Expand Down Expand Up @@ -184,35 +186,27 @@ export class PSBTBuilder extends FeeEstimator {
} while (potentialIndex++)
}

private async addInputs() {
private addInputs() {
const reservedIndexes = this.injectableInputs.map((input) => input.injectionIndex)
const injectedIndexes: number[] = []

for (let i = 0; i < this.inputs.length; i++) {
const indexReserved = reservedIndexes.includes(i)
if (indexReserved) {
const totalInputsLength = this.inputs.length + this.injectableInputs.length

let inputIndex = 0
for (let i = 0; i < totalInputsLength; i++) {
// if current index is reserved => inject input
if (reservedIndexes.includes(i)) {
// insert injectable input
const injectable = this.injectableInputs.find((o) => o.injectionIndex === i)!
this.injectInput(injectable)
injectedIndexes.push(injectable.injectionIndex)
} else {
// else => insert next input
const input = this.inputs[inputIndex]
this.psbt.addInput(input)
// set whether input is RBF or not
this.psbt.setInputSequence(i, this.getInputSequence())
inputIndex += 1
}

const existingInputHashes = this.psbt.txInputs.map((input) => {
const hash = reverseBuffer(input.hash) as Buffer
return generateTxUniqueIdentifier(hash.toString("hex"), input.index)
})

const input = this.inputs[i]
if (existingInputHashes.includes(generateTxUniqueIdentifier(input.hash, input.index))) continue

this.psbt.addInput(input)
this.psbt.setInputSequence(indexReserved ? i + 1 : i, this.getInputSequence())
}

this.injectableInputs.forEach((injectable) => {
if (injectedIndexes.includes(injectable.injectionIndex)) return
this.injectInput(injectable)
injectedIndexes.push(injectable.injectionIndex)
})
}

private validateOutputAmount() {
Expand All @@ -223,26 +217,26 @@ export class PSBTBuilder extends FeeEstimator {

private addOutputs() {
const reservedIndexes = this.injectableOutputs.map((o) => o.injectionIndex)
const injectedIndexes: number[] = []

this.outputs.forEach((output, index) => {
if (reservedIndexes.includes(index)) {
const injectable = this.injectableOutputs.find((o) => o.injectionIndex === index)!
const totalOutputLength = this.outputs.length + this.injectableOutputs.length

let outputIndex = 0
for (let i = 0; i < totalOutputLength; i++) {
// if current index is reserved => inject output
if (reservedIndexes.includes(i)) {
// insert injectable output
const injectable = this.injectableOutputs.find((o) => o.injectionIndex === i)!
this.injectOutput(injectable)
injectedIndexes.push(injectable.injectionIndex)
} else {
// else => insert next output
const output = this.outputs[outputIndex]
this.psbt.addOutput({
address: output.address,
value: output.value
})
outputIndex += 1
}

this.psbt.addOutput({
address: output.address,
value: output.value
})
})

this.injectableOutputs.forEach((injectable) => {
if (injectedIndexes.includes(injectable.injectionIndex)) return
this.injectOutput(injectable)
injectedIndexes.push(injectable.injectionIndex)
})
}

if (this.changeAmount >= MINIMUM_AMOUNT_IN_SATS) {
this.psbt.addOutput({
Expand Down Expand Up @@ -279,7 +273,10 @@ export class PSBTBuilder extends FeeEstimator {
}

private getRetrievedUTXOsValue() {
return this.utxos.reduce((acc, utxo) => (acc += utxo.sats), 0)
return (
this.utxos.reduce((acc, utxo) => (acc += utxo.sats), 0) +
this.inputs.reduce((acc, curr) => (acc += curr.witnessUtxo?.value ?? 0), 0)
)
}

private getReservedUTXOs() {
Expand All @@ -289,14 +286,16 @@ export class PSBTBuilder extends FeeEstimator {
private async retrieveUTXOs(address?: string, amount?: number) {
if (!this.autoAdjustment && !address) return

const retrievedUTXOsValue = this.getRetrievedUTXOsValue()

const amountToRequest =
amount && amount > 0
? amount
: this.changeAmount < 0
? this.changeAmount * -1
: this.outputAmount - this.getRetrievedUTXOsValue()
: this.outputAmount - retrievedUTXOsValue

if ((amount && this.getRetrievedUTXOsValue() >= amount) || amountToRequest <= 0) return
if ((amount && retrievedUTXOsValue >= amount) || amountToRequest <= 0) return

const utxos = await this.datasource.getSpendables({
address: address || this.address,
Expand Down Expand Up @@ -340,6 +339,7 @@ export class PSBTBuilder extends FeeEstimator {
const response = await Promise.all(promises)

this.inputAmount += this.injectableInputs.reduce((acc, curr) => (acc += curr.sats), 0)
this.inputAmount += this.inputs.reduce((acc, curr) => (acc += curr.witnessUtxo?.value ?? 0), 0)
for (const input of response) {
if (this.usedUTXOs.includes(generateTxUniqueIdentifier(input.hash, input.index))) continue
this.usedUTXOs.push(generateTxUniqueIdentifier(input.hash, input.index))
Expand Down Expand Up @@ -370,7 +370,7 @@ export class PSBTBuilder extends FeeEstimator {
private async process() {
this.initPSBT()

await this.addInputs()
this.addInputs()
this.addOutputs()

this.calculateNetworkFee()
Expand Down
200 changes: 200 additions & 0 deletions packages/sdk/src/transactions/PreInscriber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { Psbt } from "bitcoinjs-lib"
import reverseBuffer from "buffer-reverse"

import { BaseDatasource, decodePSBT, getScriptType, Output } from ".."
import { MINIMUM_AMOUNT_IN_SATS } from "../constants"
import { OrditSDKError } from "../utils/errors"
import { PSBTBuilder } from "./PSBTBuilder"
import { InjectableInput, InjectableOutput } from "./PSBTBuilder"

interface DecodeInscriptionPsbtsResponse {
inscriptionPsbts: Psbt[]
inscriptionOutpoints: string[]
inscriptionOutputs: Output[]
}

export class PreInscriber extends PSBTBuilder {
private readonly receiveAddress: string
private readonly inscriptionPsbts: Psbt[]
private readonly inscriptionOutpoints: string[]
private readonly extraOutputs?: Output[]

private inscriptionOutputs: Output[] = []

constructor({
buyerAddress,
datasource,
feeRate,
network,
publicKey,
inscriptionB64Psbts,
extraOutputs,
receiveAddress
}: {
buyerAddress: string
datasource?: BaseDatasource
feeRate: number
network: any
publicKey: any
inscriptionB64Psbts: string[] // signed inscription psbt from the seller/creator
extraOutputs?: Output[] // additional outputs to be included in the final transaction
receiveAddress: string
}) {
super({
address: buyerAddress,
publicKey,
datasource,
feeRate,
network,
outputs: [],
autoAdjustment: true,
instantTradeMode: true
})

this.address = buyerAddress
this.receiveAddress = receiveAddress
this.extraOutputs = extraOutputs

// decode all base64 inscription psbts
const decodedPsbts = this.decodeInscriptionPsbts(inscriptionB64Psbts)
this.inscriptionPsbts = decodedPsbts.inscriptionPsbts
this.inscriptionOutpoints = decodedPsbts.inscriptionOutpoints
this.inscriptionOutputs = decodedPsbts.inscriptionOutputs

this.rbf = false
}

private decodeInscriptionPsbts(inscriptionB64Strings: string[]): DecodeInscriptionPsbtsResponse {
const inscriptionPsbts = inscriptionB64Strings.map((b64) => decodePSBT({ base64: b64 }))
const inscriptionOutpoints = inscriptionPsbts.map((psbt) => {
return `${reverseBuffer(psbt.txInputs[0].hash).toString("hex")}:${psbt.txInputs[0].index}` // TODO: check reversebuffer
})
const inscriptionOutputs: Output[] = []

// sanity checks
inscriptionPsbts.forEach((psbt) => {
const [input] = psbt.data.inputs

// TODO: check
if (!input.witnessUtxo) {
throw new OrditSDKError("invalid seller psbt")
}
const data = getScriptType(input.witnessUtxo.script, this.network)
const sellerAddress = data.payload && data.payload.address ? data.payload.address : undefined
if (!sellerAddress) {
throw new OrditSDKError("invalid seller address in psbt")
}

// add postage to the inscription outputs
inscriptionOutputs.push({
address: this.receiveAddress, // bind to receive address
value: input.witnessUtxo.value
})
})

return { inscriptionPsbts, inscriptionOutpoints, inscriptionOutputs }
}

private createInjectableInputsAndOutputs(inscriptionPsbts: Psbt[]) {
const injectableInputs: InjectableInput[] = []
const injectableOutputs: InjectableOutput[] = []

// injection starts from 1 + number of inscriptions (1st output is for refundable utxos, 2nd onwards for inscriptions, then the psbts outputs)
const injectionIndex = this.inscriptionPsbts.length + 1

inscriptionPsbts.forEach((psbt, index) => {
// add injectable input
const hash = reverseBuffer(psbt.txInputs[0].hash).toString("hex")
const inputIndex = psbt.txInputs[0].index
injectableInputs.push({
standardInput: {
...psbt.data.inputs[0], // assumption: only 1 inputs in each psbt
hash,
index: inputIndex
// type: "taproot", // CHECK: can assume is taproot?
// tapInternalKey: psbt.data.inputs[0].tapInternalKey!
},
txInput: (psbt.data.globalMap.unsignedTx as any).tx.ins[0],
sats: psbt.data.inputs[0].witnessUtxo!.value,
injectionIndex: injectionIndex + index
} as InjectableInput)

// add injectable output
injectableOutputs.push({
standardOutput: psbt.data.outputs[0], // assumption: only 1 outputs in each psbt
txOutput: (psbt.data.globalMap.unsignedTx as any).tx.outs[0],
sats: (psbt.data.globalMap.unsignedTx as any).tx.outs[0].value,
injectionIndex: injectionIndex + index
})
})

return { injectableInputs, injectableOutputs }
}

async getRefundableUtxos(address: string, n = 2) {
const utxos = (
await this.datasource.getUnspents({
address: address,
type: "spendable",
sort: "asc" // sort by ascending order to use low amount utxos as refundable utxos
})
).spendableUTXOs.filter((utxo) => utxo.sats >= MINIMUM_AMOUNT_IN_SATS)

// n refundables utxos
if (utxos.length < n) {
throw new OrditSDKError("Not enough refundable UTXOs found")
}

// add refundable utxos. PSBTBuilder will add more utxo to fund the txs
return utxos.slice(0, n)
}

async validateInscriptions(inscriptionOutpoints: string[]) {
// warn: might need to batch this call if there are many inscriptions
await Promise.all(
inscriptionOutpoints.map(async (outpoint) => {
const res = await this.datasource.getInscriptions({ outpoint })
if (res.length === 0) {
throw new OrditSDKError(`Inscription no longer available for trade. Outpoint: ${outpoint}`)
}
return res
})
)

return true
}

async build() {
// check if inscriptions in seller psbt are valid
this.validateInscriptions(this.inscriptionOutpoints)

// check if buyer has atleast (inscriptions + 1) refundable utxos (min sat utxo)
// First inscription requires 2 refundable utxos, next inscription adds 1 more refundable utxo and so on
const refundableUtxos = await this.getRefundableUtxos(this.address, this.inscriptionPsbts.length + 1)
this.utxos = [...refundableUtxos]

// ADD combined refundable utxo amount as 1st output
this.outputs = [
{
address: this.address,
value: refundableUtxos.reduce((acc, curr) => (acc += curr.sats), 0)
}
]

// ADD inscription output as 2nd output onwards
this.inscriptionOutputs.forEach((output) => {
this.outputs.push(output)
})

// ADD extra outputs if any
if (this.extraOutputs) {
this.outputs.push(...this.extraOutputs)
}

const injectables = this.createInjectableInputsAndOutputs(this.inscriptionPsbts)
this.injectableInputs = injectables.injectableInputs
this.injectableOutputs = injectables.injectableOutputs

await this.prepare()
}
}
Loading
Loading