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(rgbpp-btc): OP_RETURN output support #18

Merged
merged 6 commits into from
Mar 10, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/gorgeous-starfishes-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rgbpp-sdk/btc": patch
---

Support creating OP_RETURN outputs in the sendBtc() API
5 changes: 5 additions & 0 deletions .changeset/silver-readers-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rgbpp-sdk/btc": patch
---

Fix the error message reading from the BtcAssetsApi response
44 changes: 39 additions & 5 deletions packages/btc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,19 @@ console.log(res);

### Constructing transaction

Transfer BTC from a P2WPKH address:
Transfer BTC from a `P2WPKH` address:

```typescript
import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc';

const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token');

const networkType = NetworkType.TESTNET;

const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token');
const source = new DataSource(service, networkType);

// Create a PSBT
const psbt = await sendBtc({
from: 'from_address', // your P2WPKH address
from: account.address, // your P2WPKH address
tos: [
{
address: 'to_address', // destination btc address
Expand All @@ -95,7 +95,41 @@ const psbt = await sendBtc({
});

// Sign & finalize inputs
psbt.signAllInputs(accounts.charlie.keyPair);
psbt.signAllInputs(account.keyPair);
psbt.finalizeAllInputs();

// Broadcast transaction
const tx = psbt.extractTransaction();
const res = await service.sendTransaction(tx.toHex());
console.log('txid:', res.txid);
```

Create an `OP_RETURN` output:

```typescript
import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc';

const networkType = NetworkType.TESTNET;

const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token');
const source = new DataSource(service, networkType);

// Create a PSBT
const psbt = await sendBtc({
from: account.address, // your address
tos: [
{
data: Buffer.from('0x' + '00'.repeat(32), 'hex'), // any data <= 80 bytes
value: 0, // normally the value is 0
},
],
feeRate: 1, // optional
networkType,
source,
});

// Sign & finalize inputs
psbt.signAllInputs(account.keyPair);
psbt.finalizeAllInputs();

// Broadcast transaction
Expand Down
9 changes: 3 additions & 6 deletions packages/btc/src/api/sendBtc.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import bitcoin from '../bitcoin';
import { NetworkType } from '../network';
import { DataSource } from '../query/source';
import { TxBuilder } from '../transaction/build';
import { TxBuilder, TxTo } from '../transaction/build';

export async function sendBtc(props: {
from: string;
tos: {
address: string;
value: number;
}[];
tos: TxTo[];
source: DataSource;
networkType: NetworkType;
minUtxoSatoshi?: number;
Expand All @@ -24,7 +21,7 @@ export async function sendBtc(props: {
});

props.tos.forEach((to) => {
tx.addOutput(to.address, to.value);
tx.addTo(to);
});

await tx.collectInputsAndPayFee(props.from);
Expand Down
4 changes: 4 additions & 0 deletions packages/btc/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export enum ErrorCodes {
UNKNOWN,
INSUFFICIENT_UTXO,
UNSUPPORTED_OUTPUT,
UNSUPPORTED_ADDRESS_TYPE,
INVALID_OP_RETURN_SCRIPT,
ASSETS_API_RESPONSE_ERROR,
ASSETS_API_UNAUTHORIZED,
ASSETS_API_INVALID_PARAM,
Expand All @@ -11,7 +13,9 @@ export enum ErrorCodes {
export const ErrorMessages = {
[ErrorCodes.UNKNOWN]: 'Unknown error',
[ErrorCodes.INSUFFICIENT_UTXO]: 'Insufficient UTXO',
[ErrorCodes.UNSUPPORTED_OUTPUT]: 'Unsupported output format',
[ErrorCodes.UNSUPPORTED_ADDRESS_TYPE]: 'Unsupported address type',
[ErrorCodes.INVALID_OP_RETURN_SCRIPT]: 'Invalid OP_RETURN script format',
[ErrorCodes.ASSETS_API_UNAUTHORIZED]: 'BtcAssetsAPI unauthorized, please check your token/origin',
[ErrorCodes.ASSETS_API_INVALID_PARAM]: 'Invalid param(s) was provided to the BtcAssetsAPI',
[ErrorCodes.ASSETS_API_RESPONSE_ERROR]: 'BtcAssetsAPI returned an error',
Expand Down
1 change: 1 addition & 0 deletions packages/btc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './query/service';
export * from './query/source';

export * from './transaction/build';
export * from './transaction/embed';
export * from './transaction/fee';

export * from './api/sendBtc';
4 changes: 3 additions & 1 deletion packages/btc/src/query/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,11 @@ export class BtcAssetsApi {
}
}
if (json && !ok) {
const innerError = json?.error?.error ? `(${json.error.error.code}) ${json.error.error.message}` : void 0;
const message = json.message ?? innerError ?? JSON.stringify(json);
throw new TxBuildError(
ErrorCodes.ASSETS_API_RESPONSE_ERROR,
`${ErrorMessages[ErrorCodes.ASSETS_API_RESPONSE_ERROR]}: ${json.message}`,
`${ErrorMessages[ErrorCodes.ASSETS_API_RESPONSE_ERROR]}: ${message}`,
);
}

Expand Down
48 changes: 40 additions & 8 deletions packages/btc/src/transaction/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { DataSource } from '../query/source';
import { ErrorCodes, TxBuildError } from '../error';
import { AddressType, UnspentOutput } from '../types';
import { NetworkType, toPsbtNetwork } from '../network';
import { addressToScriptPublicKeyHex, getAddressType } from '../address';
import { MIN_COLLECTABLE_SATOSHI } from '../constants';
import { addressToScriptPublicKeyHex, getAddressType } from '../address';
import { removeHexPrefix } from '../utils';
import { dataToOpReturnScriptPubkey } from './embed';
import { FeeEstimator } from './fee';

interface TxInput {
Expand All @@ -18,10 +20,21 @@ interface TxInput {
utxo: UnspentOutput;
}

interface TxOutput {
export type TxOutput = TxAddressOutput | TxScriptOutput;
export interface TxAddressOutput {
address: string;
value: number;
}
export interface TxScriptOutput {
script: Buffer;
value: number;
}

export type TxTo = TxAddressOutput | TxDataOutput;
export interface TxDataOutput {
data: Buffer | string;
value: number;
}

export class TxBuilder {
inputs: TxInput[] = [];
Expand Down Expand Up @@ -49,14 +62,30 @@ export class TxBuilder {
}

addInput(utxo: UnspentOutput) {
utxo = clone(utxo);
this.inputs.push(utxoToInput(utxo));
}

addOutput(address: string, value: number) {
this.outputs.push({
address,
value,
});
addOutput(output: TxOutput) {
output = clone(output);
this.outputs.push(output);
}

addTo(to: TxTo) {
if ('data' in to) {
const data = typeof to.data === 'string' ? Buffer.from(removeHexPrefix(to.data), 'hex') : to.data;
const scriptPubkey = dataToOpReturnScriptPubkey(data);

return this.addOutput({
script: scriptPubkey,
value: to.value,
});
}
if ('address' in to) {
return this.addOutput(to);
}

throw new TxBuildError(ErrorCodes.UNSUPPORTED_OUTPUT);
}

async collectInputsAndPayFee(address: string, fee?: number, extraChange?: number): Promise<void> {
Expand Down Expand Up @@ -92,7 +121,10 @@ export class TxBuilder {
`collected satoshi: ${satoshi}, collected utxos: [${this.inputs.map((u) => u.utxo.value)}], returning change: ${changeSatoshi}`,
);
if (requireChangeUtxo) {
this.addOutput(this.changedAddress, changeSatoshi);
this.addOutput({
address: this.changedAddress,
value: changeSatoshi,
});
}

const addressType = getAddressType(address);
Expand Down
72 changes: 72 additions & 0 deletions packages/btc/src/transaction/embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import bitcoin from 'bitcoinjs-lib';
import { ErrorCodes, TxBuildError } from '../error';

/**
* Convert data to OP_RETURN script pubkey.
* The data size should be ranged in 1 to 80 bytes.
*
* @example
* const data = Buffer.from('01020304', 'hex');
* const scriptPk = dataToOpReturnScriptPubkey(data); // <Buffer 6a 04 01 02 03 04>
* const scriptPkHex = scriptPk.toString('hex'); // 6a0401020304
*/
export function dataToOpReturnScriptPubkey(data: Buffer): Buffer {
const payment = bitcoin.payments.embed({ data: [data] });
return payment.output!;
}

/**
* Get data from a OP_RETURN script pubkey.
*
* @example
* const scriptPk = Buffer.from('6a0401020304', 'hex');
* const data = opReturnScriptPubKeyToData(scriptPk); // <Buffer 01 02 03 04>
* const hex = data.toString('hex'); // 01020304
*/
export function opReturnScriptPubKeyToData(script: Buffer): Buffer {
if (!isOpReturnScriptPubkey(script)) {
throw new TxBuildError(ErrorCodes.INVALID_OP_RETURN_SCRIPT);
}

const [_op, data] = bitcoin.script.decompile(script)!;
return data as Buffer;
}

/**
* Check if a script pubkey is an OP_RETURN script.
*
* A valid OP_RETURN script should have the following structure:
* - <OP_RETURN code> <size: n> <data of n bytes>
* - <OP_RETURN code> <OP_PUSHDATA1> <size: n> <data of n bytes>
*
* @example
* // <OP_RETURN> <size: 0x04> <data: 01020304>
* isOpReturnScriptPubkey(Buffer.from('6a0401020304', 'hex')); // true
* // <OP_RETURN> <OP_PUSHDATA1> <size: 0x0f> <data: 746573742d636f6d6d69746d656e74>
* isOpReturnScriptPubkey(Buffer.from('6a4c0f746573742d636f6d6d69746d656e74', 'hex')); // true
* // <OP_RETURN> <OP_PUSHDATA1>
* isOpReturnScriptPubkey(Buffer.from('6a4c', 'hex')); // false
* // <OP_RETURN> <size: 0x01>
* isOpReturnScriptPubkey(Buffer.from('6a01', 'hex')); // false
* // <OP_DUP> ... (not an OP_RETURN script)
* isOpReturnScriptPubkey(Buffer.from('76a914a802fc56c704ce87c42d7c92eb75e7896bdc41e788ac', 'hex')); // false
*/
export function isOpReturnScriptPubkey(script: Buffer): boolean {
const scripts = bitcoin.script.decompile(script);
if (!scripts || scripts.length !== 2) {
return false;
}

const [op, data] = scripts!;
// OP_RETURN opcode is 0x6a in hex or 106 in integer
if (op !== bitcoin.opcodes.OP_RETURN) {
return false;
}
// Standard OP_RETURN data size is up to 80 bytes
if (!(data instanceof Buffer) || data.byteLength < 1 || data.byteLength > 80) {
return false;
}

// No false condition matched, it's an OP_RETURN script
return true;
}
7 changes: 7 additions & 0 deletions packages/btc/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ export function isDomain(domain: string): boolean {
const regex = /^(?:[-A-Za-z0-9]+\.)+[A-Za-z]{2,}$/;
return regex.test(domain);
}

/**
* Remove '0x' prefix from a hex string.
*/
export function removeHexPrefix(hex: string): string {
return hex.startsWith('0x') ? hex.slice(2) : hex;
}
40 changes: 40 additions & 0 deletions packages/btc/tests/Embed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { dataToOpReturnScriptPubkey, opReturnScriptPubKeyToData } from '../src/transaction/embed';

describe('Embed', () => {
it('Encode UTF-8 data to OP_RETURN script pubkey', () => {
const data = Buffer.from('test-commitment', 'utf-8');
const script = dataToOpReturnScriptPubkey(data);

expect(script.toString('hex')).toEqual('6a0f746573742d636f6d6d69746d656e74');
});
it('Decode UTF-8 data from OP_RETURN script pubkey', () => {
const script = Buffer.from('6a0f746573742d636f6d6d69746d656e74', 'hex');
const data = opReturnScriptPubKeyToData(script);

expect(data.toString('utf-8')).toEqual('test-commitment');
});

it('Decode 32-byte hex from OP_RETURN script pubkey', () => {
const hex = '00'.repeat(32);
const script = Buffer.from('6a20' + hex, 'hex');
const data = opReturnScriptPubKeyToData(script);

expect(data.toString('hex')).toEqual(hex);
});

it('Encode 80-byte data to OP_RETURN script pubkey', () => {
const hex = '00'.repeat(80);
const data = Buffer.from(hex, 'hex');
const script = dataToOpReturnScriptPubkey(data);

expect(script.toString('hex')).toEqual('6a4c50' + hex);
});
it('Decode 80-byte hex from OP_RETURN script pubkey', () => {
const hex = '00'.repeat(80);
const script = Buffer.from('6a4c50' + hex, 'hex');
const data = opReturnScriptPubKeyToData(script);

expect(data.toString('hex')).toEqual(hex);
});
});
Loading
Loading