Skip to content

Commit 50ff6c4

Browse files
authored
fix: amount calculation in the rgbpp address balance endpoint (#206)
Merge pull request #206 from ckb-cell/fix/202-unconfirmed-xudt-balance
2 parents e97816b + 776ec6d commit 50ff6c4

File tree

6 files changed

+140
-51
lines changed

6 files changed

+140
-51
lines changed

src/routes/rgbpp/address.ts

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,23 @@ import { ZodTypeProvider } from 'fastify-type-provider-zod';
55
import { CKBTransaction, Cell, IsomorphicTransaction, Script, XUDTBalance } from './types';
66
import z from 'zod';
77
import { Env } from '../../env';
8-
import { buildPreLockArgs, getXudtTypeScript, isScriptEqual, isTypeAssetSupported } from '@rgbpp-sdk/ckb';
9-
import { groupBy } from 'lodash';
8+
import {
9+
isScriptEqual,
10+
buildPreLockArgs,
11+
getRgbppLockScript,
12+
getXudtTypeScript,
13+
isTypeAssetSupported,
14+
} from '@rgbpp-sdk/ckb';
15+
import { groupBy, uniq } from 'lodash';
1016
import { BI } from '@ckb-lumos/lumos';
1117
import { UTXO } from '../../services/bitcoin/schema';
1218
import { Transaction as BTCTransaction } from '../bitcoin/types';
1319
import { TransactionWithStatus } from '../../services/ckb';
1420
import { computeScriptHash } from '@ckb-lumos/lumos/utils';
1521
import { filterCellsByTypeScript, getTypeScript } from '../../utils/typescript';
22+
import { unpackRgbppLockArgs } from '@rgbpp-sdk/btc/lib/ckb/molecule';
23+
import { TestnetTypeMap } from '../../constants';
24+
import { remove0x } from '@rgbpp-sdk/btc';
1625

1726
const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodTypeProvider> = (fastify, _, done) => {
1827
const env: Env = fastify.container.resolve('env');
@@ -52,6 +61,18 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
5261
return cells;
5362
}
5463

64+
/**
65+
* Filter RgbppLock cells by cells
66+
*/
67+
function getRgbppLockCellsByCells(cells: Cell[]): Cell[] {
68+
const rgbppLockScript = getRgbppLockScript(env.NETWORK === 'mainnet', TestnetTypeMap[env.NETWORK]);
69+
return cells.filter(
70+
(cell) =>
71+
rgbppLockScript.codeHash === cell.cellOutput.lock.codeHash &&
72+
rgbppLockScript.hashType === cell.cellOutput.lock.hashType,
73+
);
74+
}
75+
5576
fastify.get(
5677
'/:btc_address/assets',
5778
{
@@ -104,7 +125,12 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
104125
'/:btc_address/balance',
105126
{
106127
schema: {
107-
description: 'Get RGB++ balance by btc address, support xUDT only for now',
128+
description: `
129+
Get RGB++ balance by btc address, support xUDT only for now.
130+
131+
An address with more than 50 pending BTC transactions is uncommon.
132+
However, if such a situation arises, it potentially affecting the returned total_amount.
133+
`,
108134
tags: ['RGB++'],
109135
params: z.object({
110136
btc_address: z.string(),
@@ -147,13 +173,14 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
147173
throw fastify.httpErrors.badRequest('Unsupported type asset');
148174
}
149175

150-
const utxos = await getUxtos(btc_address, no_cache);
151176
const xudtBalances: Record<string, XUDTBalance> = {};
177+
const utxos = await getUxtos(btc_address, no_cache);
152178

153-
let cells = await getRgbppAssetsCells(btc_address, utxos, no_cache);
154-
cells = typeScript ? filterCellsByTypeScript(cells, typeScript) : cells;
155-
156-
const availableXudtBalances = await fastify.rgbppCollector.getRgbppBalanceByCells(cells);
179+
// Find confirmed RgbppLock XUDT assets
180+
const confirmedUtxos = utxos.filter((utxo) => utxo.status.confirmed);
181+
const confirmedCells = await getRgbppAssetsCells(btc_address, confirmedUtxos, no_cache);
182+
const confirmedTargetCells = filterCellsByTypeScript(confirmedCells, typeScript);
183+
const availableXudtBalances = await fastify.rgbppCollector.getRgbppBalanceByCells(confirmedTargetCells);
157184
Object.keys(availableXudtBalances).forEach((key) => {
158185
const { amount, ...xudtInfo } = availableXudtBalances[key];
159186
xudtBalances[key] = {
@@ -164,6 +191,7 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
164191
};
165192
});
166193

194+
// Find all unconfirmed RgbppLock XUDT outputs
167195
const pendingUtxos = utxos.filter(
168196
(utxo) =>
169197
!utxo.status.confirmed ||
@@ -172,19 +200,14 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
172200
);
173201
const pendingUtxosGroup = groupBy(pendingUtxos, (utxo) => utxo.txid);
174202
const pendingTxids = Object.keys(pendingUtxosGroup);
175-
176203
const pendingOutputCellsGroup = await Promise.all(
177204
pendingTxids.map(async (txid) => {
178-
const cells = await fastify.transactionProcessor.getPendingOuputCellsByTxid(txid);
205+
const cells = await fastify.transactionProcessor.getPendingOutputCellsByTxid(txid);
179206
const lockArgsSet = new Set(pendingUtxosGroup[txid].map((utxo) => buildPreLockArgs(utxo.vout)));
180207
return cells.filter((cell) => lockArgsSet.has(cell.cellOutput.lock.args));
181208
}),
182209
);
183-
let pendingOutputCells = pendingOutputCellsGroup.flat();
184-
if (typeScript) {
185-
pendingOutputCells = filterCellsByTypeScript(pendingOutputCells, typeScript);
186-
}
187-
210+
const pendingOutputCells = filterCellsByTypeScript(pendingOutputCellsGroup.flat(), typeScript);
188211
const pendingXudtBalances = await fastify.rgbppCollector.getRgbppBalanceByCells(pendingOutputCells);
189212
Object.values(pendingXudtBalances).forEach(({ amount, type_hash, ...xudtInfo }) => {
190213
if (!xudtBalances[type_hash]) {
@@ -200,6 +223,50 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
200223
xudtBalances[type_hash].pending_amount = BI.from(xudtBalances[type_hash].pending_amount)
201224
.add(BI.from(amount))
202225
.toHexString();
226+
});
227+
228+
// Find spent RgbppLock XUDT assets in the inputs of the unconfirmed transactions
229+
// XXX: the bitcoin.getAddressTxs() API only returns up to 50 mempool transactions
230+
const latestTxs = await fastify.bitcoin.getAddressTxs({ address: btc_address });
231+
const unconfirmedTxids = latestTxs.filter((tx) => !tx.status.confirmed).map((tx) => tx.txid);
232+
const spendingInputCellsGroup = await Promise.all(
233+
unconfirmedTxids.map(async (txid) => {
234+
const inputCells = await fastify.transactionProcessor.getPendingInputCellsByTxid(txid);
235+
const inputRgbppCells = getRgbppLockCellsByCells(filterCellsByTypeScript(inputCells, typeScript));
236+
const inputCellLockArgs = inputRgbppCells.map((cell) => unpackRgbppLockArgs(cell.cellOutput.lock.args));
237+
238+
const txids = uniq(inputCellLockArgs.map((args) => remove0x(args.btcTxid)));
239+
const txs = await Promise.all(txids.map((txid) => fastify.bitcoin.getTx({ txid })));
240+
const txsMap = txs.reduce(
241+
(sum, tx, index) => {
242+
const txid = txids[index];
243+
sum[txid] = tx ?? null;
244+
return sum;
245+
},
246+
{} as Record<string, BTCTransaction | null>,
247+
);
248+
249+
return inputRgbppCells.filter((cell, index) => {
250+
const lockArgs = inputCellLockArgs[index];
251+
const tx = txsMap[remove0x(lockArgs.btcTxid)];
252+
const utxo = tx?.vout[lockArgs.outIndex];
253+
return utxo?.scriptpubkey_address === btc_address;
254+
});
255+
}),
256+
);
257+
const spendingInputCells = spendingInputCellsGroup.flat();
258+
const spendingXudtBalances = await fastify.rgbppCollector.getRgbppBalanceByCells(spendingInputCells);
259+
Object.values(spendingXudtBalances).forEach(({ amount, type_hash, ...xudtInfo }) => {
260+
if (!xudtBalances[type_hash]) {
261+
xudtBalances[type_hash] = {
262+
...xudtInfo,
263+
type_hash,
264+
total_amount: '0x0',
265+
available_amount: '0x0',
266+
pending_amount: '0x0',
267+
};
268+
}
269+
203270
xudtBalances[type_hash].total_amount = BI.from(xudtBalances[type_hash].total_amount)
204271
.add(BI.from(amount))
205272
.toHexString();
@@ -322,18 +389,18 @@ const addressRoutes: FastifyPluginCallback<Record<never, never>, Server, ZodType
322389
} as const;
323390
}
324391

325-
const inputOutpoints = isomorphicTx.ckbRawTx?.inputs || isomorphicTx.ckbTx?.inputs || [];
326-
const inputs = await fastify.ckb.getInputCellsByOutPoint(
327-
inputOutpoints.map((input) => input.previousOutput) as CKBComponents.OutPoint[],
328-
);
392+
const inputs = isomorphicTx.ckbRawTx?.inputs || isomorphicTx.ckbTx?.inputs || [];
393+
const inputCells = await fastify.ckb.getInputCellsByOutPoint(inputs.map((input) => input.previousOutput!));
394+
const inputCellOutputs = inputCells.map((cell) => cell.cellOutput);
395+
329396
const outputs = isomorphicTx.ckbRawTx?.outputs || isomorphicTx.ckbTx?.outputs || [];
330397

331398
return {
332399
btcTx,
333400
isRgbpp: true,
334401
isomorphicTx: {
335402
...isomorphicTx,
336-
inputs,
403+
inputs: inputCellOutputs,
337404
outputs,
338405
},
339406
} as const;

src/services/ckb.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
import { computeScriptHash } from '@ckb-lumos/lumos/utils';
2323
import DataCache from './base/data-cache';
2424
import { scriptToHash } from '@nervosnetwork/ckb-sdk-utils';
25-
import { OutputCell } from '../routes/rgbpp/types';
25+
import { Cell } from '../routes/rgbpp/types';
26+
import { uniq } from 'lodash';
2627

2728
export type TransactionWithStatus = Awaited<ReturnType<CKBRPC['getTransaction']>>;
2829

@@ -326,14 +327,27 @@ export default class CKBClient {
326327
return null;
327328
}
328329

329-
public async getInputCellsByOutPoint(outPoints: CKBComponents.OutPoint[]): Promise<OutputCell[]> {
330-
const batchRequest = this.rpc.createBatchRequest(outPoints.map((outPoint) => ['getTransaction', outPoint.txHash]));
331-
const txs = await batchRequest.exec();
332-
const inputs = txs.map((tx: TransactionWithStatus, index: number) => {
333-
const outPoint = outPoints[index];
334-
return tx.transaction.outputs[BI.from(outPoint.index).toNumber()];
330+
public async getInputCellsByOutPoint(outPoints: CKBComponents.OutPoint[]): Promise<Cell[]> {
331+
const txHashes = uniq(outPoints.map((outPoint) => outPoint.txHash));
332+
const batchRequest = this.rpc.createBatchRequest(txHashes.map((txHash) => ['getTransaction', txHash]));
333+
const txs: TransactionWithStatus[] = await batchRequest.exec();
334+
const txsMap = txs.reduce(
335+
(acc, tx: TransactionWithStatus) => {
336+
acc[tx.transaction.hash] = tx;
337+
return acc;
338+
},
339+
{} as Record<string, TransactionWithStatus>,
340+
);
341+
return outPoints.map((outPoint) => {
342+
const tx = txsMap[outPoint.txHash];
343+
const outPointIndex = BI.from(outPoint.index).toNumber();
344+
return Cell.parse({
345+
cellOutput: tx.transaction.outputs[outPointIndex],
346+
data: tx.transaction.outputsData[outPointIndex],
347+
blockHash: tx.txStatus.blockHash,
348+
outPoint,
349+
});
335350
});
336-
return inputs;
337351
}
338352

339353
/**

src/services/transaction.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,7 @@ export default class TransactionProcessor
593593
* get pending output cells by txid, get ckb output cells from the uncompleted job
594594
* @param txid - the transaction id
595595
*/
596-
public async getPendingOuputCellsByTxid(txid: string) {
596+
public async getPendingOutputCellsByTxid(txid: string): Promise<Cell[]> {
597597
const job = await this.getTransactionRequest(txid);
598598
if (!job) {
599599
return [];
@@ -608,14 +608,34 @@ export default class TransactionProcessor
608608
const { ckbVirtualResult } = job.data;
609609
const outputs = ckbVirtualResult.ckbRawTx.outputs;
610610
return outputs.map((output, index) => {
611-
const cell: Cell = {
611+
return Cell.parse({
612612
cellOutput: output,
613613
data: ckbVirtualResult.ckbRawTx.outputsData[index],
614-
};
615-
return cell;
614+
});
616615
});
617616
}
618617

618+
/**
619+
* get pending input cells by txid, get ckb input cells from the uncompleted job
620+
* @param txid - the transaction id
621+
*/
622+
public async getPendingInputCellsByTxid(txid: string): Promise<Cell[]> {
623+
const job = await this.getTransactionRequest(txid);
624+
if (!job) {
625+
return [];
626+
}
627+
628+
// get ckb input cells from the uncompleted job only
629+
const state = await job.getState();
630+
if (state === 'completed' || state === 'failed') {
631+
return [];
632+
}
633+
634+
const { ckbVirtualResult } = job.data;
635+
const inputOutPoints = ckbVirtualResult.ckbRawTx.inputs.map((input) => input.previousOutput!);
636+
return await this.cradle.ckb.getInputCellsByOutPoint(inputOutPoints);
637+
}
638+
619639
/**
620640
* Retry all failed jobs in the queue
621641
* @param maxAttempts - the max attempts to retry

test/routes/__snapshots__/token.test.ts.snap

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,5 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`\`/token/generate\` - 400 1`] = `
4-
"[
5-
{
6-
"code": "invalid_type",
7-
"expected": "object",
8-
"received": "null",
9-
"path": [],
10-
"message": "Expected object, received null"
11-
}
12-
]"
13-
`;
14-
153
exports[`\`/token/generate\` - without params 1`] = `
164
"[
175
{

test/routes/rgbpp/__snapshots__/address.test.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6889,7 +6889,7 @@ exports[`/:btc_address/balance - with pending_amount 1`] = `
68896889
"name": "Unique BBQ",
68906890
"pending_amount": "0x5f5e100",
68916891
"symbol": "",
6892-
"total_amount": "0xbebc200",
6892+
"total_amount": "0x5f5e100",
68936893
"type_hash": "0x78e21efcf107e7886eadeadecd1a01cfb88f1e5617f4438685db55b3a540d202",
68946894
"type_script": {
68956895
"args": "0x30d3fbec9ceba691770d57c6d06bdb98cf0f82bef0ca6e87687a118d6ce1e7b7",
@@ -6903,7 +6903,7 @@ exports[`/:btc_address/balance - with pending_amount 1`] = `
69036903
"name": "XUDT Test Token",
69046904
"pending_amount": "0x5f5e100",
69056905
"symbol": "PDD",
6906-
"total_amount": "0x5f5e100",
6906+
"total_amount": "0x0",
69076907
"type_hash": "0x10f511f2efb0027191b97ac5b4bd77374ffdac7399e8527d76f5f9bd32e7d35b",
69086908
"type_script": {
69096909
"args": "0x8c556e92974a8dd8237719020a259d606359ac2cc958cb8bda77a1c3bb3cd93b",

test/routes/rgbpp/address.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ describe('/rgbpp/v1/address', () => {
9292
mockRgbppUtxoPairs as RgbppUtxoCellsPair[],
9393
);
9494
const transactionProcessor = fastify.container.resolve('transactionProcessor');
95-
const getPendingOuputCellsByTxidSpy = vi
96-
.spyOn(transactionProcessor, 'getPendingOuputCellsByTxid')
95+
const getPendingOutputCellsByTxidSpy = vi
96+
.spyOn(transactionProcessor, 'getPendingOutputCellsByTxid')
9797
.mockResolvedValueOnce([
9898
{
9999
cellOutput: {
@@ -155,11 +155,11 @@ describe('/rgbpp/v1/address', () => {
155155
});
156156
const data = response.json();
157157

158-
expect(getPendingOuputCellsByTxidSpy).toBeCalledTimes(2);
159-
expect(getPendingOuputCellsByTxidSpy).toHaveBeenCalledWith(
158+
expect(getPendingOutputCellsByTxidSpy).toBeCalledTimes(2);
159+
expect(getPendingOutputCellsByTxidSpy).toHaveBeenCalledWith(
160160
'aab2d8fc3f064087450057ccb6012893cf219043d8c915fe64c5322c0eeb6fd2',
161161
);
162-
expect(getPendingOuputCellsByTxidSpy).toHaveBeenCalledWith(
162+
expect(getPendingOutputCellsByTxidSpy).toHaveBeenCalledWith(
163163
'989f4e03179e17cbb6edd446f57ea6107a40ba23441056653f1cc34b7dd1e5ba',
164164
);
165165
expect(response.statusCode).toBe(200);

0 commit comments

Comments
 (0)