Skip to content

Commit

Permalink
Merge pull request #408 from bob-collective/feat/taproot-address
Browse files Browse the repository at this point in the history
Enable taproot address
  • Loading branch information
gregdhill authored Nov 20, 2024
2 parents 955e892 + 4ba6d93 commit 97b4080
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 51 deletions.
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gobob/bob-sdk",
"version": "3.0.3",
"version": "3.0.4",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './esplora';
export * from './relay';
export * from './utils';
export * from './ordinals';
export * from './ordinal-api';
export * from './helpers';
export * from './wallet';
export * from './gateway';
Expand Down
90 changes: 76 additions & 14 deletions sdk/src/ordinal-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export module SatPoint {
/**
* @ignore
*/
// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L117-L121
// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L147-L151
export interface InscriptionsJson<InscriptionId> {
/**
* An array of inscription ids.
Expand All @@ -106,7 +106,7 @@ export interface InscriptionsJson<InscriptionId> {
/**
* @ignore
*/
// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L124-L134
// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L154-L165
export interface OutputJson {
/**
* The address associated with the UTXO.
Expand Down Expand Up @@ -134,10 +134,15 @@ export interface OutputJson {
};
};

/**
* The outpoint.
*/
outpoint: string;

/**
* The SAT ranges.
*/
sat_ranges: string | null;
sat_ranges: [number, number][] | null;

/**
* The scriptPubKey associated with the UTXO.
Expand All @@ -163,7 +168,30 @@ export interface OutputJson {
/**
* @ignore
*/
// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L165-L180
// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L228-L233
export interface AddressInfo {
/**
* An array of output ids.
*/
outputs: string[];
/**
* An array of inscription ids.
*/
inscriptions: string[];
/**
* Balance in satoshi.
*/
sat_balance: number;
/**
* A list of runes.
*/
runes_balances: [string, `${number}`, string][];
}

/**
* @ignore
*/
// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L197-L213
export interface SatJson<InscriptionId> {
/**
* The number of the ordinal.
Expand Down Expand Up @@ -241,7 +269,7 @@ export interface SatJson<InscriptionId> {
/**
* @ignore
*/
// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L80-L99
// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L93-L113
export interface InscriptionJson<InscriptionId, SatPoint> {
/**
* The address associated with the inscription.
Expand All @@ -250,6 +278,8 @@ export interface InscriptionJson<InscriptionId, SatPoint> {

charms: string[];

child_count: number;

/**
* An array of child IDs.
*/
Expand Down Expand Up @@ -292,11 +322,6 @@ export interface InscriptionJson<InscriptionId, SatPoint> {
*/
number: number;

/**
* The parent inscription IDs.
*/
parent: InscriptionId | null;

/**
* The parent inscription IDs.
*/
Expand All @@ -315,7 +340,7 @@ export interface InscriptionJson<InscriptionId, SatPoint> {
/**
* The SAT associated with the inscription.
*/
sat: string | null;
sat: number | null;

/**
* The SAT point of the inscription, this is the current UTXO.
Expand Down Expand Up @@ -352,6 +377,22 @@ export class OrdinalsClient {
}
}

/**
* Retrieves address information.
* @param {string} address - The address to request information about.
* @returns {Promise<AddressInfo>} A Promise that resolves to the address information.
*
* @example
* ```typescript
* const client = new OrdinalsClient("regtest");
* const addressInfo = await client.getAssetsByAddress("enter_your_address_here");
* console.log("AddressInfo:", addressInfo);
* ```
*/
getAssetsByAddress(address: string): Promise<AddressInfo> {
return this.getJson<AddressInfo>(`${this.basePath}/address/${address}`);
}

/**
* Retrieves an inscription based on its ID.
* @param {string} id - The ID of the inscription to retrieve.
Expand All @@ -375,7 +416,6 @@ export class OrdinalsClient {
children: inscriptionJson.children.map(InscriptionId.fromString),
id: InscriptionId.fromString(inscriptionJson.id),
next: inscriptionJson.next != null ? InscriptionId.fromString(inscriptionJson.next) : null,
parent: inscriptionJson.parent != null ? InscriptionId.fromString(inscriptionJson.parent) : null,
previous: inscriptionJson.previous != null ? InscriptionId.fromString(inscriptionJson.previous) : null,
satpoint: SatPoint.fromString(inscriptionJson.satpoint),
};
Expand Down Expand Up @@ -432,8 +472,30 @@ export class OrdinalsClient {
* console.log("Output:", output);
* ```
*/
async getInscriptionsFromOutPoint(outPoint: OutPoint): Promise<OutputJson> {
return await this.getJson<OutputJson>(`${this.basePath}/output/${OutPoint.toString(outPoint)}`);
getInscriptionsFromOutPoint(outPoint: OutPoint): Promise<OutputJson> {
return this.getJson<OutputJson>(`${this.basePath}/output/${OutPoint.toString(outPoint)}`);
}

/**
* Retrieves inscriptions based on the address.
* @param {String} address - The Bitcoin address to check.
* @param {('cardinal' | 'inscribed' | 'runic' | 'any')} [type] - Optional type of UTXOs to be returned. If omitted returns all UTXOs.
* @returns {Promise<OutputJson>} A Promise that resolves to the inscription data.
*
* @example
* ```typescript
* const client = new OrdinalsClient("regtest");
* const address: string = "enter_address_here";
* const type: 'cardinal' | 'inscribed' | 'runic' = "enter_type_here";
* const output = await client.getOutputsFromAddress(address, type?);
* console.log("Output:", output);
* ```
*/
getOutputsFromAddress(address: string, type?: 'cardinal' | 'inscribed' | 'runic' | 'any'): Promise<OutputJson[]> {
const searchParams = new URLSearchParams();
if (type) searchParams.append('type', type);
// https://docs.ordinals.com/guides/api.html#description-19
return this.getJson<OutputJson[]>(`${this.basePath}/outputs/${address}?${searchParams}`);
}

/**
Expand Down
92 changes: 65 additions & 27 deletions sdk/src/wallet/utxo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Transaction, Script, selectUTXO, TEST_NETWORK, NETWORK, p2wpkh, p2sh } from '@scure/btc-signer';
import { Transaction, Script, selectUTXO, TEST_NETWORK, NETWORK, p2wpkh, p2sh, p2tr } from '@scure/btc-signer';
import { hex, base64 } from '@scure/base';
import { AddressType, getAddressInfo, Network } from 'bitcoin-address-validation';
import { EsploraClient, UTXO } from '../esplora';
import { OrdinalsClient, OutPoint, OutputJson } from '../ordinal-api';

export type BitcoinNetworkName = Exclude<Network, 'regtest'>;

Expand Down Expand Up @@ -69,36 +70,43 @@ export async function createBitcoinPsbt(
const addressInfo = getAddressInfo(fromAddress);

// TODO: possibly, allow other strategies to be passed to this function
const utxoSelectionStrategy = 'default';
const utxoSelectionStrategy: 'all' | 'default' = 'default';

if (addressInfo.network === 'regtest') {
throw new Error('Bitcoin regtest not supported');
}

// We need the public key to generate the redeem and witness script to spend the scripts
if (addressInfo.type === (AddressType.p2sh || AddressType.p2wsh)) {
if (
addressInfo.type === AddressType.p2sh ||
addressInfo.type === AddressType.p2wsh ||
addressInfo.type === AddressType.p2tr
) {
if (!publicKey) {
throw new Error('Public key is required to spend from the selected address type');
}
}

const esploraClient = new EsploraClient(addressInfo.network);
const ordinalsClient = new OrdinalsClient(addressInfo.network);

let confirmedUtxos: UTXO[] = [];
// contains UTXOs which do not contain inscriptions
let outputsFromAddress: OutputJson[] = [];

if (feeRate) {
confirmedUtxos = await esploraClient.getAddressUtxos(fromAddress);
} else {
[confirmedUtxos, feeRate] = await Promise.all([
esploraClient.getAddressUtxos(fromAddress),
esploraClient.getFeeEstimate(confirmationTarget),
]);
}
[confirmedUtxos, feeRate, outputsFromAddress] = await Promise.all([
esploraClient.getAddressUtxos(fromAddress),
feeRate === undefined ? esploraClient.getFeeEstimate(confirmationTarget) : feeRate,
// cardinal = return UTXOs not containing inscriptions or runes
addressInfo.type === AddressType.p2tr ? ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal') : [],
]);

if (confirmedUtxos.length === 0) {
throw new Error('No confirmed UTXOs');
}

const outpointsSet = new Set(outputsFromAddress.map((output) => output.outpoint));

// To construct the spending transaction and estimate the fee, we need the transactions for the UTXOs
const possibleInputs: Input[] = [];

Expand All @@ -113,7 +121,12 @@ export async function createBitcoinPsbt(
addressInfo.type,
publicKey
);
possibleInputs.push(input);
// to support taproot addresses we want to exclude outputs which contain inscriptions
if (addressInfo.type === AddressType.p2tr) {
if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input);
} else {
possibleInputs.push(input);
}
})
);

Expand Down Expand Up @@ -156,6 +169,10 @@ export async function createBitcoinPsbt(
console.debug(`fromAddress: ${fromAddress}, toAddress: ${toAddress}, amount: ${amount}`);
console.debug(`publicKey: ${publicKey}, opReturnData: ${opReturnData}`);
console.debug(`feeRate: ${feeRate}, confirmationTarget: ${confirmationTarget}`);
if (addressInfo.type === AddressType.p2tr) {
console.debug('confirmedUtxos', confirmedUtxos);
console.debug('outputsFromAddress', outputsFromAddress);
}
throw new Error('Failed to create transaction. Do you have enough funds?');
}

Expand Down Expand Up @@ -186,6 +203,9 @@ export function getInputFromUtxoAndTx(
}
const inner = p2wpkh(Buffer.from(publicKey!, 'hex'), getBtcNetwork(network));
redeemScript = p2sh(inner);
} else if (addressType === AddressType.p2tr) {
const xOnlyPublicKey = Buffer.from(publicKey, 'hex').subarray(1, 33);
redeemScript = p2tr(xOnlyPublicKey);
}

// For the redeem and witness script, we need to construct the script mixin
Expand Down Expand Up @@ -267,7 +287,11 @@ export async function estimateTxFee(
}

// We need the public key to generate the redeem and witness script to spend the scripts
if (addressInfo.type === (AddressType.p2sh || AddressType.p2wsh)) {
if (
addressInfo.type === AddressType.p2sh ||
addressInfo.type === AddressType.p2wsh ||
addressInfo.type === AddressType.p2tr
) {
if (!publicKey) {
throw new Error('Public key is required to spend from the selected address type');
}
Expand All @@ -279,33 +303,44 @@ export async function estimateTxFee(
// TODO: allow submitting the UTXOs, fee estimate and confirmed transactions
// to avoid fetching them again.
const esploraClient = new EsploraClient(addressInfo.network);
const ordinalsClient = new OrdinalsClient(addressInfo.network);

let confirmedUtxos: UTXO[] = [];
if (feeRate) {
confirmedUtxos = await esploraClient.getAddressUtxos(fromAddress);
} else {
[confirmedUtxos, feeRate] = await Promise.all([
esploraClient.getAddressUtxos(fromAddress),
esploraClient.getFeeEstimate(confirmationTarget),
]);
}
// contains UTXOs which do not contain inscriptions
let outputsFromAddress: OutputJson[] = [];

[confirmedUtxos, feeRate, outputsFromAddress] = await Promise.all([
esploraClient.getAddressUtxos(fromAddress),
feeRate === undefined ? esploraClient.getFeeEstimate(confirmationTarget) : feeRate,
// cardinal = return UTXOs not containing inscriptions or runes
addressInfo.type === AddressType.p2tr ? ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal') : [],
]);

if (confirmedUtxos.length === 0) {
throw new Error('No confirmed UTXOs');
}

const possibleInputs = await Promise.all(
const outpointsSet = new Set(outputsFromAddress.map((output) => output.outpoint));
const possibleInputs: Input[] = [];

await Promise.all(
confirmedUtxos.map(async (utxo) => {
const hex = await esploraClient.getTransactionHex(utxo.txid);
const transaction = Transaction.fromRaw(Buffer.from(hex, 'hex'), { allowUnknownOutputs: true });

return getInputFromUtxoAndTx(
const input = getInputFromUtxoAndTx(
addressInfo.network as BitcoinNetworkName,
utxo,
transaction,
addressInfo.type,
publicKey
);

// to support taproot addresses we want to exclude outputs which contain inscriptions
if (addressInfo.type === AddressType.p2tr) {
if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input);
} else {
possibleInputs.push(input);
}
})
);

Expand All @@ -330,7 +365,7 @@ export async function estimateTxFee(
}

// Select all UTXOs if no amount is specified
let utxoSelectionStrategy = 'default';
let utxoSelectionStrategy: 'all' | 'default' = 'default';
if (amount === undefined) {
utxoSelectionStrategy = 'all';
}
Expand All @@ -339,8 +374,7 @@ export async function estimateTxFee(
// https://github.com/paulmillr/scure-btc-signer?tab=readme-ov-file#utxo-selection
// default = exactBiggest/accumBiggest creates tx with smallest fees, but it breaks
// big outputs to small ones, which in the end will create a lot of outputs close to dust.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const transaction = selectUTXO(possibleInputs, outputs, utxoSelectionStrategy as any, {
const transaction = selectUTXO(possibleInputs, outputs, utxoSelectionStrategy, {
changeAddress: fromAddress, // Refund surplus to the payment address
feePerByte: BigInt(Math.ceil(feeRate)), // round up to the nearest integer
bip69: true, // Sort inputs and outputs according to BIP69
Expand All @@ -356,6 +390,10 @@ export async function estimateTxFee(
console.debug(`fromAddress: ${fromAddress}, amount: ${amount}`);
console.debug(`publicKey: ${publicKey}, opReturnData: ${opReturnData}`);
console.debug(`feeRate: ${feeRate}, confirmationTarget: ${confirmationTarget}`);
if (addressInfo.type === AddressType.p2tr) {
console.debug('confirmedUtxos', confirmedUtxos);
console.debug('outputsFromAddress', outputsFromAddress);
}
throw new Error('Failed to create transaction. Do you have enough funds?');
}

Expand Down
2 changes: 1 addition & 1 deletion sdk/test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('Gateway Tests', () => {
}).rejects.toThrowError('Invalid output chain');
});

it('should start order', async () => {
it('should start order', { timeout: 50000 }, async () => {
const gatewaySDK = new GatewaySDK('bob');

const mockQuote = {
Expand Down
Loading

0 comments on commit 97b4080

Please sign in to comment.