fix(brc20): historical token balance (#444)

* feat: start indexing brc20 balance history

* fix: api support

* style: revert

* remove extra
This commit is contained in:
Rafael Cárdenas
2025-02-20 10:59:25 -06:00
committed by GitHub
parent ebad427cea
commit 41438aca96
5 changed files with 392 additions and 121 deletions

View File

@@ -82,38 +82,36 @@ export class Brc20PgStore extends BasePgStore {
): Promise<DbPaginatedResult<DbBrc20Balance>> {
const ticker = sqlOr(
this.sql,
args.ticker?.map(t => this.sql`d.ticker LIKE LOWER(${t}) || '%'`)
args.ticker?.map(t => this.sql`b.ticker LIKE LOWER(${t}) || '%'`)
);
// Change selection table depending if we're filtering by block height or not.
const results = await this.sql<(DbBrc20Balance & { total: number })[]>`
SELECT
b.ticker, (SELECT decimals FROM tokens WHERE ticker = b.ticker) AS decimals,
b.avail_balance, b.trans_balance, b.total_balance, COUNT(*) OVER() as total
${
args.block_height
? this.sql`
SELECT
d.ticker, d.decimals,
SUM(b.avail_balance) AS avail_balance,
SUM(b.trans_balance) AS trans_balance,
SUM(b.avail_balance + b.trans_balance) AS total_balance,
COUNT(*) OVER() as total
FROM operations AS b
INNER JOIN tokens AS d ON d.ticker = b.ticker
FROM balances_history b
INNER JOIN (
SELECT ticker, address, MAX(block_height) as max_block_height
FROM balances_history
WHERE address = ${args.address} AND block_height <= ${args.block_height}
GROUP BY ticker, address
) latest ON b.ticker = latest.ticker AND b.address = latest.address AND b.block_height = latest.max_block_height
WHERE
b.address = ${args.address}
AND b.block_height <= ${args.block_height}
b.total_balance > 0
${ticker ? this.sql`AND ${ticker}` : this.sql``}
GROUP BY d.ticker, d.decimals
HAVING SUM(b.avail_balance + b.trans_balance) > 0
`
: this.sql`
SELECT d.ticker, d.decimals, b.avail_balance, b.trans_balance, b.total_balance, COUNT(*) OVER() as total
FROM balances AS b
INNER JOIN tokens AS d ON d.ticker = b.ticker
WHERE
b.total_balance > 0
AND b.address = ${args.address}
${ticker ? this.sql`AND ${ticker}` : this.sql``}
`
}
ORDER BY b.total_balance DESC
LIMIT ${args.limit}
OFFSET ${args.offset}
`;

View File

@@ -1400,4 +1400,231 @@ describe('BRC-20 API', () => {
expect(response.statusCode).toBe(404);
});
});
describe('/brc-20/balances', () => {
test('address balance history is accurate', async () => {
// Setup
const numbers = incrementing(0);
const addressA = 'bc1q6uwuet65rm6xvlz7ztw2gvdmmay5uaycu03mqz';
const addressB = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4';
// A deploys pepe
let transferHash = randomHash();
let blockHash = randomHash();
await brc20TokenDeploy(brc20Db.sql, {
ticker: 'pepe',
display_ticker: 'pepe',
inscription_id: `${transferHash}i0`,
inscription_number: numbers.next().value.toString(),
block_height: '780000',
block_hash: blockHash,
tx_id: transferHash,
tx_index: 0,
address: addressA,
max: '21000000000000000000000000',
limit: '21000000000000000000000000',
decimals: 18,
self_mint: false,
minted_supply: '0',
tx_count: 1,
timestamp: 1677803510,
operation: 'deploy',
ordinal_number: '20000',
output: `${transferHash}:0`,
offset: '0',
to_address: null,
amount: '0',
});
// A mints 10000 pepe
transferHash = randomHash();
blockHash = randomHash();
await brc20Operation(brc20Db.sql, {
ticker: 'pepe',
operation: 'mint',
inscription_id: `${transferHash}i0`,
inscription_number: numbers.next().value.toString(),
ordinal_number: '200000',
block_height: '780050',
block_hash: blockHash,
tx_id: transferHash,
tx_index: 0,
output: `${transferHash}:0`,
offset: '0',
timestamp: 1677803510,
address: addressA,
to_address: null,
amount: '10000000000000000000000',
});
// A mints 10000 pepe again
transferHash = randomHash();
blockHash = randomHash();
await brc20Operation(brc20Db.sql, {
ticker: 'pepe',
operation: 'mint',
inscription_id: `${transferHash}i0`,
inscription_number: numbers.next().value.toString(),
ordinal_number: '200000',
block_height: '780060',
block_hash: blockHash,
tx_id: transferHash,
tx_index: 0,
output: `${transferHash}:0`,
offset: '0',
timestamp: 1677803510,
address: addressA,
to_address: null,
amount: '10000000000000000000000',
});
// B mints 10000 pepe
transferHash = randomHash();
blockHash = randomHash();
await brc20Operation(brc20Db.sql, {
ticker: 'pepe',
operation: 'mint',
inscription_id: `${transferHash}i0`,
inscription_number: numbers.next().value.toString(),
ordinal_number: '200000',
block_height: '780070',
block_hash: blockHash,
tx_id: transferHash,
tx_index: 0,
output: `${transferHash}:0`,
offset: '0',
timestamp: 1677803510,
address: addressB,
to_address: null,
amount: '10000000000000000000000',
});
// A deploys test
transferHash = randomHash();
blockHash = randomHash();
await brc20TokenDeploy(brc20Db.sql, {
ticker: 'test',
display_ticker: 'test',
inscription_id: `${transferHash}i0`,
inscription_number: numbers.next().value.toString(),
block_height: '780100',
block_hash: blockHash,
tx_id: transferHash,
tx_index: 0,
address: addressA,
max: '21000000000000000000000000',
limit: '21000000000000000000000000',
decimals: 18,
self_mint: false,
minted_supply: '0',
tx_count: 1,
timestamp: 1677803510,
operation: 'deploy',
ordinal_number: '20000',
output: `${transferHash}:0`,
offset: '0',
to_address: null,
amount: '0',
});
// A mints 10000 test
transferHash = randomHash();
blockHash = randomHash();
await brc20Operation(brc20Db.sql, {
ticker: 'test',
operation: 'mint',
inscription_id: `${transferHash}i0`,
inscription_number: numbers.next().value.toString(),
ordinal_number: '200000',
block_height: '780200',
block_hash: blockHash,
tx_id: transferHash,
tx_index: 0,
output: `${transferHash}:0`,
offset: '0',
timestamp: 1677803510,
address: addressA,
to_address: null,
amount: '10000000000000000000000',
});
// Verify balance history across block intervals
let response = await fastify.inject({
method: 'GET',
url: `/ordinals/brc-20/balances/${addressA}`,
});
expect(response.statusCode).toBe(200);
let json = response.json();
expect(json.total).toBe(2);
expect(json.results).toEqual(
expect.arrayContaining([
{
available_balance: '20000.000000000000000000',
overall_balance: '20000.000000000000000000',
ticker: 'pepe',
transferrable_balance: '0.000000000000000000',
},
{
available_balance: '10000.000000000000000000',
overall_balance: '10000.000000000000000000',
ticker: 'test',
transferrable_balance: '0.000000000000000000',
},
])
);
response = await fastify.inject({
method: 'GET',
url: `/ordinals/brc-20/balances/${addressA}?block_height=780200`,
});
expect(response.statusCode).toBe(200);
json = response.json();
expect(json.total).toBe(2);
expect(json.results).toEqual(
expect.arrayContaining([
{
available_balance: '20000.000000000000000000',
overall_balance: '20000.000000000000000000',
ticker: 'pepe',
transferrable_balance: '0.000000000000000000',
},
{
available_balance: '10000.000000000000000000',
overall_balance: '10000.000000000000000000',
ticker: 'test',
transferrable_balance: '0.000000000000000000',
},
])
);
response = await fastify.inject({
method: 'GET',
url: `/ordinals/brc-20/balances/${addressA}?block_height=780200&ticker=te`,
});
expect(response.statusCode).toBe(200);
json = response.json();
expect(json.total).toBe(1);
expect(json.results).toEqual(
expect.arrayContaining([
{
available_balance: '10000.000000000000000000',
overall_balance: '10000.000000000000000000',
ticker: 'test',
transferrable_balance: '0.000000000000000000',
},
])
);
response = await fastify.inject({
method: 'GET',
url: `/ordinals/brc-20/balances/${addressA}?block_height=780050`,
});
expect(response.statusCode).toBe(200);
json = response.json();
expect(json.total).toBe(1);
expect(json.results).toEqual(
expect.arrayContaining([
{
available_balance: '10000.000000000000000000',
overall_balance: '10000.000000000000000000',
ticker: 'pepe',
transferrable_balance: '0.000000000000000000',
},
])
);
});
});
});

View File

@@ -490,6 +490,20 @@ export async function brc20Operation(sql: PgSqlClient, operation: TestBrc20Opera
trans_balance = balances.trans_balance + EXCLUDED.trans_balance,
total_balance = balances.avail_balance + EXCLUDED.total_balance
`;
await sql`
INSERT INTO balances_history
(ticker, address, block_height, avail_balance, trans_balance, total_balance)
(
SELECT ticker, address, ${operation.block_height} AS block_height, avail_balance,
trans_balance, total_balance
FROM balances
WHERE address = ${operation.address} AND ticker = ${operation.ticker}
)
ON CONFLICT (address, block_height, ticker) DO UPDATE SET
avail_balance = EXCLUDED.avail_balance,
trans_balance = EXCLUDED.trans_balance,
total_balance = EXCLUDED.total_balance
`;
}
/** Generate a random hash like string for testing */