Skip to content

Commit

Permalink
Merge pull request #13 from ckb-cell/feat/verify-paymaster-utxo
Browse files Browse the repository at this point in the history
feat: verify paymaster btc_utxo when needPaymasterCell is true
  • Loading branch information
ahonn authored Apr 2, 2024
2 parents c710559 + 307a4f2 commit 3bbd7de
Show file tree
Hide file tree
Showing 11 changed files with 222 additions and 22 deletions.
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

0 comments on commit 3bbd7de

Please sign in to comment.