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: verify paymaster btc_utxo when needPaymasterCell is true #13

Merged
merged 10 commits into from
Apr 2, 2024
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ PAYMASTER_PRIVATE_KEY=
# Paymaster cell capacity in shannons
PAYMASTER_CELL_CAPACITY=31600000000
PAYMASTER_CELL_PRESET_COUNT=500
PAYMASTER_CELL_REFILL_THRESHOLD=0.3
PAYMASTER_RECEIVE_BTC_ADDRESS=
PAYMASTER_BTC_CONTAINER_FEE_SATS=7000

# BTCTimeLock cell unlock batch size
UNLOCKER_CELL_BATCH_SIZE=100
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
BITCOIN_JSON_RPC_PASSWORD: ${{ secrets.BITCOIN_JSON_RPC_PASSWORD }}
BITCOIN_ELECTRS_API_URL: ${{ secrets.BITCOIN_ELECTRS_API_URL }}
BITCOIN_SPV_SERVICE_URL: ${{ secrets.BITCOIN_SPV_SERVICE_URL }}
PAYMASTER_RECEIVE_BTC_ADDRESS: ${{ secrets.PAYMASTER_RECEIVE_BTC_ADDRESS }}
CKB_RPC_URL: ${{ secrets.CKB_RPC_URL }}
CKB_INDEXER_URL: ${{ secrets.CKB_INDEXER_URL }}
PAYMASTER_PRIVATE_KEY: ${{ secrets.PAYMASTER_PRIVATE_KEY }}
Expand Down
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const envSchema = z.object({
PAYMASTER_CELL_CAPACITY: z.coerce.number().default(316 * 10 ** 8),
PAYMASTER_CELL_PRESET_COUNT: z.coerce.number().default(500),
PAYMASTER_CELL_REFILL_THRESHOLD: z.coerce.number().default(0.3),
PAYMASTER_RECEIVE_BTC_ADDRESS: z.string().optional(),
PAYMASTER_BTC_CONTAINER_FEE_SATS: z.coerce.number().default(7000),

UNLOCKER_CRON_SCHEDULE: z.string().default('*/5 * * * *'),
UNLOCKER_CELL_BATCH_SIZE: z.coerce.number().default(100),
Expand Down
3 changes: 3 additions & 0 deletions src/routes/rgbpp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { ZodTypeProvider } from 'fastify-type-provider-zod';
import assetsRoute from './assets';
import addressRoutes from './address';
import spvRoute from './spv';
import paymasterRoutes from './paymaster';

const rgbppRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodTypeProvider> = (fastify, _, done) => {
fastify.decorate('transactionManager', container.resolve('transactionManager'));
fastify.decorate('paymaster', container.resolve('paymaster'));
fastify.decorate('ckbRPC', container.resolve('ckbRpc'));
fastify.decorate('ckbIndexer', container.resolve('ckbIndexer'));
fastify.decorate('electrs', container.resolve('electrs'));
Expand All @@ -18,6 +20,7 @@ const rgbppRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodTypePr
fastify.register(assetsRoute, { prefix: '/assets' });
fastify.register(addressRoutes, { prefix: '/address' });
fastify.register(spvRoute, { prefix: '/btc-spv' });
fastify.register(paymasterRoutes, { prefix: '/paymaster' });
done();
};

Expand Down
36 changes: 36 additions & 0 deletions src/routes/rgbpp/paymaster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { HttpStatusCode } from 'axios';
import { FastifyPluginCallback } from 'fastify';
import { ZodTypeProvider } from 'fastify-type-provider-zod';
import { Server } from 'http';
import z from 'zod';

const paymasterRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodTypeProvider> = (fastify, _, done) => {
fastify.get(
'/info',
{
schema: {
description: 'Get RGB++ paymaster information',
tags: ['RGB++'],
response: {
200: z.object({
btc_address: z.string().describe('Bitcoin address to send funds to'),
fee: z.coerce.number().describe('Container fee in satoshis'),
}),
},
},
},
async (_, reply) => {
const btc_address = fastify.paymaster.btcAddress;
if (!btc_address) {
reply.status(HttpStatusCode.NotFound);
return;
}

const fee = fastify.paymaster.containerFee;
return { btc_address, fee };
},
);
done();
};

export default paymasterRoutes;
53 changes: 47 additions & 6 deletions src/services/paymaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DelayedError, Queue, Worker } from 'bullmq';
import { AppendPaymasterCellAndSignTxParams, IndexerCell, appendPaymasterCellAndSignCkbTx } from '@rgbpp-sdk/ckb';
import { hd, config, BI } from '@ckb-lumos/lumos';
import * as Sentry from '@sentry/node';
import { Transaction } from '../routes/bitcoin/types';

interface IPaymaster {
getNextCell(token: string): Promise<IndexerCell | null>;
Expand Down Expand Up @@ -57,7 +58,7 @@ export default class Paymaster implements IPaymaster {
}

private get lockScript() {
const args = hd.key.privateKeyToBlake160(this.privateKey);
const args = hd.key.privateKeyToBlake160(this.ckbPrivateKey);
const scripts =
this.cradle.env.NETWORK === 'mainnet' ? config.predefined.LINA.SCRIPTS : config.predefined.AGGRON4.SCRIPTS;
const template = scripts['SECP256K1_BLAKE160']!;
Expand Down Expand Up @@ -91,21 +92,34 @@ export default class Paymaster implements IPaymaster {
private async getContext() {
const remaining = await this.queue.getWaitingCount();
return {
address: this.address,
address: this.ckbAddress,
remaining: remaining,
preset: this.presetCount,
threshold: this.refillThreshold,
};
}

public get privateKey() {
/*
* Get the private key of the paymaster ckb address, used to sign the transaction.
*/
public get ckbPrivateKey() {
return this.cradle.env.PAYMASTER_PRIVATE_KEY;
}

public get address() {
/**
* is the paymaster receives UTXO check enabled
*/
public get enablePaymasterReceivesUTXOCheck() {
return !!this.cradle.env.PAYMASTER_RECEIVE_BTC_ADDRESS;
}

/**
* The paymaster CKB address to pay the time cells spent tx fee
*/
public get ckbAddress() {
const isMainnet = this.cradle.env.NETWORK === 'mainnet';
const lumosConfig = isMainnet ? config.predefined.LINA : config.predefined.AGGRON4;
const args = hd.key.privateKeyToBlake160(this.privateKey);
const args = hd.key.privateKeyToBlake160(this.ckbPrivateKey);
const template = lumosConfig.SCRIPTS['SECP256K1_BLAKE160'];
const lockScript = {
codeHash: template.CODE_HASH,
Expand All @@ -117,6 +131,33 @@ export default class Paymaster implements IPaymaster {
});
}

/**
* The paymaster BTC address to receive the BTC UTXO
*/
public get btcAddress() {
return this.cradle.env.PAYMASTER_RECEIVE_BTC_ADDRESS;
}

/**
* The paymaster container fee in sats
* Paymaster received utxo should be greater than or equal to the container fee
*/
public get containerFee() {
// XXX: fixed fee for now, may change in the future
return this.cradle.env.PAYMASTER_BTC_CONTAINER_FEE_SATS;
}

/**
* Check if the paymaster has received the BTC UTXO
* @param btcTx - the BTC transaction
*/
public hasPaymasterReceivedBtcUTXO(btcTx: Transaction) {
const hasVaildOutput = btcTx.vout.some((output) => {
return output.scriptpubkey_address === this.btcAddress && output.value >= this.containerFee;
});
return hasVaildOutput;
}

/**
* Get the next paymaster cell from the queue
* will refill the queue if the count is less than the threshold
Expand Down Expand Up @@ -235,7 +276,7 @@ export default class Paymaster implements IPaymaster {
ckbRawTx,
sumInputsCapacity,
paymasterCell,
secp256k1PrivateKey: this.privateKey,
secp256k1PrivateKey: this.ckbPrivateKey,
isMainnet: this.cradle.env.NETWORK === 'mainnet',
});
this.cradle.logger.info(`[Paymaster] Signed transaction: ${JSON.stringify(signedTx)}`);
Expand Down
21 changes: 16 additions & 5 deletions src/services/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,17 @@ export const TRANSACTION_QUEUE_NAME = 'rgbpp-ckb-transaction-queue';
class InvalidTransactionError extends Error {
public data: ITransactionRequest;

constructor(data: ITransactionRequest) {
super('Invalid transaction');
this.name = 'InvalidTransactionError';
constructor(message: string, data: ITransactionRequest) {
super(message);
this.name = this.constructor.name;
this.data = data;
}
}

class OpReturnNotFoundError extends Error {
constructor(txid: string) {
super(`OP_RETURN output not found: ${txid}`);
this.name = 'OpReturnNotFoundError';
this.name = this.constructor.name;
}
}

Expand Down Expand Up @@ -299,7 +299,7 @@ export default class TransactionManager implements ITransactionManager {
const btcTx = await this.cradle.electrs.getTransaction(txid);
const isVerified = await this.verifyTransaction({ ckbVirtualResult, txid }, btcTx);
if (!isVerified) {
throw new InvalidTransactionError(job.data);
throw new InvalidTransactionError('Invalid transaction', job.data);
}

const ckbRawTx = this.getCkbRawTxWithRealBtcTxid(ckbVirtualResult, txid);
Expand All @@ -319,6 +319,17 @@ export default class TransactionManager implements ITransactionManager {

// append paymaster cell and sign the transaction if needed
if (ckbVirtualResult.needPaymasterCell) {
if (this.cradle.paymaster.enablePaymasterReceivesUTXOCheck) {
// make sure the paymaster received a UTXO as container fee
const hasPaymasterUTXO = this.cradle.paymaster.hasPaymasterReceivedBtcUTXO(btcTx);
if (!hasPaymasterUTXO) {
this.cradle.logger.info(`[TransactionManager] Paymaster receives UTXO not found: ${txid}`);
throw new InvalidTransactionError('Paymaster receives UTXO not found', job.data);
}
} else {
this.cradle.logger.warn(`[TransactionManager] Paymaster receives UTXO check disabled`);
}

const tx = await this.cradle.paymaster.appendCellAndSignTx(txid, {
...ckbVirtualResult,
ckbRawTx: signedTx!,
Expand Down
4 changes: 2 additions & 2 deletions src/services/unlocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ export default class Unlocker implements IUnlocker {
isMainnet: this.isMainnet,
});
const signedTx = await signBtcTimeCellSpentTx({
secp256k1PrivateKey: this.cradle.paymaster.privateKey,
masterCkbAddress: this.cradle.paymaster.address,
secp256k1PrivateKey: this.cradle.paymaster.ckbPrivateKey,
masterCkbAddress: this.cradle.paymaster.ckbAddress,
collector,
ckbRawTx,
isMainnet: this.isMainnet,
Expand Down
3 changes: 2 additions & 1 deletion test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ test('`/docs/json` - 200', async () => {
'/rgbpp/v1/assets/{btc_txid}',
'/rgbpp/v1/assets/{btc_txid}/{vout}',
'/rgbpp/v1/address/{btc_address}/assets',
'/rgbpp/v1/btc-spv/proof'
'/rgbpp/v1/btc-spv/proof',
'/rgbpp/v1/paymaster/info'
]);

await fastify.close();
Expand Down
49 changes: 49 additions & 0 deletions test/routes/rgbpp/paymaster.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeEach, expect, test } from 'vitest';
import { buildFastify } from '../../../src/app';
import { describe } from 'node:test';
import { Env } from '../../../src/env';

let token: string;

describe('/bitcoin/v1/paymaster', () => {
beforeEach(async () => {
const fastify = buildFastify();
await fastify.ready();

const response = await fastify.inject({
method: 'POST',
url: '/token/generate',
payload: {
app: 'test',
domain: 'test.com',
},
});
const data = response.json();
token = data.token;

await fastify.close();
});

test('Get paymaster btc address', async () => {
const fastify = buildFastify();
await fastify.ready();

const env: Env = fastify.container.resolve('env');

const response = await fastify.inject({
method: 'GET',
url: '/rgbpp/v1/paymaster/info',
headers: {
Authorization: `Bearer ${token}`,
Origin: 'https://test.com',
},
});
const data = response.json();

expect(response.statusCode).toBe(200);
expect(data.btc_address).toEqual(env.PAYMASTER_RECEIVE_BTC_ADDRESS);
expect(data.fee).toEqual(env.PAYMASTER_BTC_CONTAINER_FEE_SATS);

await fastify.close();
});
});
Loading
Loading