Skip to content

Commit

Permalink
Merge pull request #18 from ckb-cell/feat/op-return-output
Browse files Browse the repository at this point in the history
feat(rgbpp-btc): OP_RETURN output support
  • Loading branch information
Flouse authored Mar 10, 2024
2 parents bcfc3fa + 6212c30 commit 09de175
Show file tree
Hide file tree
Showing 13 changed files with 314 additions and 63 deletions.
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

1 comment on commit 09de175

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[{"name":"@rgbpp-sdk/btc","version":"0.0.0-snap-20240310025258"},{"name":"@rgbpp-sdk/ckb","version":"0.0.0-snap-20240310025258"}]

Please sign in to comment.