From 507abb9ccfe3397b373623d8a83909b1ade3bf68 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Sat, 17 Oct 2026 10:53:16 -0400 Subject: [PATCH] Combine destroyed tokens and burn sends in holders --- .../[asset]/_components/activity-tabs.tsx | 27 ++++++++++++------- .../[asset]/_components/launch-view.tsx | 1 + apps/web/src/lib/holders.ts | 13 ++++++++- apps/web/src/locales/_source.json | 6 +++++ apps/web/src/locales/es.json | 4 ++- apps/web/src/locales/fr.json | 4 ++- apps/web/src/locales/ja.json | 4 ++- apps/web/src/locales/ko.json | 4 ++- apps/web/src/locales/pt.json | 4 ++- apps/web/src/locales/ru.json | 4 ++- apps/web/src/locales/uk.json | 4 ++- apps/web/src/locales/zh-hk.json | 4 ++- apps/web/src/locales/zh-tw.json | 4 ++- apps/web/src/locales/zh.json | 4 ++- tests/holders.test.ts | 14 ++++++++++ 15 files changed, 81 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx b/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx index 14b1db89..8a0f182f 100644 --- a/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx +++ b/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx @@ -39,6 +39,8 @@ import { createTradeHistoryReader, tradeFingerprint } from "@/lib/api/trade-hist import { xcpOrderRemaining } from "@/lib/order-legs"; import { tradeRoleForAddress } from "@launchpad/xcp69/trades"; import { + BURNED_ROW, + withBurnedRow, currentHolderCount, includeFormerHolders, poolLockStatus, @@ -102,8 +104,10 @@ export function ActivityTabs({ poolXcpRaw, poolTokensRaw, lpAsset, + burnedQuantity = "0", }: { asset: string; + burnedQuantity?: Raw; mints: Fairmint[]; divisible: boolean; /** During the sale there is no market yet: no trades, no book. */ @@ -221,14 +225,14 @@ export function ActivityTabs({ // Counterparty's balance endpoint is a live snapshot, not holder history. // Restore absent minters/traders at zero so the table can show who sold out, // while the tab/header/card counts above remain strictly current balances. - const holderHistory = includeFormerHolders( + const holderHistory = withBurnedRow(includeFormerHolders( holders ?? [], [ ...mints.map((mint) => mint.source), ...(roomState?.trades ?? []).map((trade) => trade.address), ...(trades ?? []).map((trade) => trade.addr), ], - ); + ), BURN_ADDRESS, burnedQuantity); // One row: the pool is a single concentration of supply, and how much of it // is locked is a property OF that row, not a second holder. const poolLock = poolLockStatus( @@ -245,11 +249,11 @@ export function ActivityTabs({ : holderHistory; const holderBalance = new Map( holderRows - .filter((row) => row.address !== POOL_ROW) + .filter((row) => row.address !== POOL_ROW && row.address !== BURNED_ROW) .map((row) => [row.address, row.quantity]), ); - // Shares are of circulating PLUS the locked pool, which is what makes them - // read as shares of supply rather than of whatever is left over. + // Display shares include the pool and removed tokens once, so destruction + // doesn't make the remaining holders' shares of this distribution inflate. const holderTotal = sumRaw(holderRows.map((h) => h.quantity)); /** @@ -454,7 +458,7 @@ export function ActivityTabs({ tab === "minters" ? minters.slice(from, from + PER_PAGE).map((r) => r.source) : tab === "holders" - ? holderRows.slice(from, from + PER_PAGE).map((h) => h.address) + ? holderRows.slice(from, from + PER_PAGE).filter(h => h.address !== POOL_ROW && h.address !== BURNED_ROW).map((h) => h.address) : [], ); @@ -837,6 +841,7 @@ export function ActivityTabs({ {holderRows.slice(from, from + PER_PAGE).map((h, i) => { const pct = ratio(h.quantity, holderTotal) * 100; const isPool = h.address === POOL_ROW; + const isBurned = h.address === BURNED_ROW; const isUtxo = h.address.startsWith("utxo:"); const soldOut = h.quantity === 0n; const displayedQuantity = tokenQty(h.quantity, divisible); @@ -858,7 +863,11 @@ export function ActivityTabs({ {from + i + 1} - {isPool ? ( + {isBurned ? ( + + 🔥 {t("Burned")} + + ) : isPool ? ( <> {/* The chip follows the LP, not the pool. Liquidity whose LP is burned can never be withdrawn; whatever @@ -923,11 +932,11 @@ export function ActivityTabs({ doesn't mean anything for a holder who bought in on the market; only the issuer's own row is a fact worth flagging post-graduation. */} - + />} {soldOut ? ( diff --git a/apps/web/src/app/[lang]/[asset]/_components/launch-view.tsx b/apps/web/src/app/[lang]/[asset]/_components/launch-view.tsx index 7a442a75..301ef839 100644 --- a/apps/web/src/app/[lang]/[asset]/_components/launch-view.tsx +++ b/apps/web/src/app/[lang]/[asset]/_components/launch-view.tsx @@ -776,6 +776,7 @@ export function LaunchView({ row.address !== burnAddress && row.address !== BURNED_ROW); + const quantity = big(burnedQuantity); + if (quantity > 0n) result.push({ address: BURNED_ROW, quantity }); + return result.sort((a, b) => compareRawDesc(a.quantity, b.quantity)); +} + /** * One row per ownership location, including locations whose live balance is * zero. Counterparty can return an address more than once (for example an @@ -37,7 +48,7 @@ export function currentHolderCount( rows: HolderRow[], excludedAddresses: Iterable = [], ): number { - const excluded = new Set(excludedAddresses); + const excluded = new Set([BURNED_ROW, ...excludedAddresses]); return rows.filter((row) => row.quantity > 0n && !excluded.has(row.address)).length; } diff --git a/apps/web/src/locales/_source.json b/apps/web/src/locales/_source.json index d1a38f89..2f57d72b 100644 --- a/apps/web/src/locales/_source.json +++ b/apps/web/src/locales/_source.json @@ -1065,6 +1065,9 @@ "burn address": [ "app/[lang]/[asset]/_components/address-badges.tsx" ], + "Burned": [ + "app/[lang]/[asset]/_components/activity-tabs.tsx" + ], "Burner": [ "app/[lang]/activity/_components/activity-view.tsx" ], @@ -1983,6 +1986,9 @@ "In XCP terms, exactly. In fiat terms, only if XCP's price held during the week-long window. Refunds return quantity, not value.": [ "app/[lang]/faq/page.tsx" ], + "Includes tokens sent to the burn address and explicitly destroyed tokens.": [ + "app/[lang]/[asset]/_components/activity-tabs.tsx" + ], "Inscribe the image on-chain.": [ "app/[lang]/create/page.tsx" ], diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 524eb8ef..514ebf19 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "Terminar de lanzar {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "El commit de la inscripción de {asset} está transmitido, pero su reveal no. El lanzamiento solo existe cuando el reveal está en la cadena, y hasta entonces el BTC del commit espera en una salida que solo ese reveal puede gastar.", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "El commit de la inscripción de {asset} nunca llegó a la cadena, y una de las monedas que gasta ya la gastó otra transacción confirmada, así que nunca llegará. Nada de su BTC espera un reveal, y puedes empezar un lanzamiento nuevo.", - "No expiry": "Sin vencimiento" + "No expiry": "Sin vencimiento", + "Burned": "Quemado", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Incluye los tokens enviados a la dirección de quema y los tokens destruidos explícitamente." } diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index f378b4c7..a7c76d7c 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "Terminer le lancement de {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "Le commit de l'inscription de {asset} est diffusé, mais pas son reveal. Le lancement n'existe qu'une fois le reveal on-chain ; d'ici là, les BTC du commit attendent dans une sortie que seul ce reveal peut dépenser.", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "Le commit de l'inscription de {asset} n'est jamais arrivé on-chain, et une des pièces qu'il dépense a depuis été dépensée par une autre transaction confirmée : il n'y arrivera donc jamais. Aucun de ses BTC n'attend de reveal, et vous pouvez commencer un nouveau lancement.", - "No expiry": "Sans expiration" + "No expiry": "Sans expiration", + "Burned": "Brûlés", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Comprend les tokens envoyés à l’adresse de destruction et les tokens explicitement détruits." } diff --git a/apps/web/src/locales/ja.json b/apps/web/src/locales/ja.json index 3e0ee062..8e20833f 100644 --- a/apps/web/src/locales/ja.json +++ b/apps/web/src/locales/ja.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "{asset} のローンチを完了", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "{asset} の刻印のコミットはブロードキャスト済みですが、リビールはまだです。ローンチはリビールがオンチェーンになって初めて成立し、それまでコミットの BTC は、そのリビールだけが使える出力で待機します。", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} の刻印のコミットはオンチェーンに届かず、それが使うコインの一つはその後、別の承認済みトランザクションで使われたため、今後も届くことはありません。リビールを待っている BTC はなく、新しいローンチを始められます。", - "No expiry": "有効期限なし" + "No expiry": "有効期限なし", + "Burned": "バーン済み", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "バーンアドレスに送信されたトークンと、明示的に破棄されたトークンを含みます。" } diff --git a/apps/web/src/locales/ko.json b/apps/web/src/locales/ko.json index 8b79558a..1aea1588 100644 --- a/apps/web/src/locales/ko.json +++ b/apps/web/src/locales/ko.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "{asset} 런칭 마치기", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "{asset} 인스크립션의 커밋은 전송되었지만 리빌은 아직입니다. 런칭은 리빌이 온체인에 올라가야 성립하며, 그때까지 커밋의 BTC는 그 리빌만 사용할 수 있는 출력에 머뭅니다.", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 인스크립션의 커밋은 온체인에 올라가지 못했고, 커밋이 사용하는 코인 하나가 이후 다른 확정된 트랜잭션에서 사용되어 앞으로도 올라갈 수 없습니다. 리빌을 기다리는 BTC는 없으며, 새 런칭을 시작할 수 있습니다.", - "No expiry": "만료 없음" + "No expiry": "만료 없음", + "Burned": "소각됨", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "소각 주소로 전송된 토큰과 명시적으로 파기된 토큰을 포함합니다." } diff --git a/apps/web/src/locales/pt.json b/apps/web/src/locales/pt.json index c6ed1b28..b35b2407 100644 --- a/apps/web/src/locales/pt.json +++ b/apps/web/src/locales/pt.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "Concluir o lançamento de {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "O commit da inscrição de {asset} foi transmitido, mas o reveal não. O lançamento só existe quando o reveal está on-chain e, até lá, o BTC do commit espera numa saída que só esse reveal pode gastar.", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "O commit da inscrição de {asset} nunca chegou à chain, e uma das moedas que ele gasta já foi gasta por outra transação confirmada, então nunca vai chegar. Nenhum BTC dele espera um reveal, e você pode começar um novo lançamento.", - "No expiry": "Sem expiração" + "No expiry": "Sem expiração", + "Burned": "Queimados", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Inclui tokens enviados ao endereço de queima e tokens explicitamente destruídos." } diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json index d45f86b6..44ad49ac 100644 --- a/apps/web/src/locales/ru.json +++ b/apps/web/src/locales/ru.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "Завершить запуск {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "Commit записи {asset} отправлен в сеть, а reveal — нет. Запуск появляется только когда reveal в блокчейне, а до тех пор BTC из commit ждут в выходе, который может потратить только этот reveal.", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "Commit записи {asset} так и не попал в блокчейн, а одну из монет, которые он тратит, уже потратила другая подтверждённая транзакция, так что он туда никогда не попадёт. Никакие его BTC не ждут reveal, и можно начать новый запуск.", - "No expiry": "Бессрочно" + "No expiry": "Бессрочно", + "Burned": "Сожжено", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включает токены, отправленные на адрес сжигания, и токены, уничтоженные явно." } diff --git a/apps/web/src/locales/uk.json b/apps/web/src/locales/uk.json index b88ff58e..6c42b839 100644 --- a/apps/web/src/locales/uk.json +++ b/apps/web/src/locales/uk.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "Завершити запуск {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "Commit інскрипції {asset} надіслано в мережу, а reveal — ні. Запуск з’являється лише тоді, коли reveal у блокчейні, а доти BTC з commit чекають у виході, який може витратити лише цей reveal.", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "Commit інскрипції {asset} так і не потрапив у блокчейн, а одну з монет, які він витрачає, уже витратила інша підтверджена транзакція, тож він туди ніколи не потрапить. Жодні його BTC не чекають на reveal, і можна почати новий запуск.", - "No expiry": "Безстроково" + "No expiry": "Безстроково", + "Burned": "Спалено", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включає токени, надіслані на адресу спалювання, і токени, знищені явно." } diff --git a/apps/web/src/locales/zh-hk.json b/apps/web/src/locales/zh-hk.json index b5ac2827..11f6660f 100644 --- a/apps/web/src/locales/zh-hk.json +++ b/apps/web/src/locales/zh-hk.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "完成發射 {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "{asset} 銘刻的 commit 已廣播,但 reveal 尚未廣播。reveal 上鏈後發射才算成立;在此之前,commit 的 BTC 會停留在只有該 reveal 才能花費的輸出中。", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 銘刻的 commit 始終沒有上鏈,而它花費的一枚幣已被另一筆已確認的交易花掉,所以它永遠不會上鏈。它的 BTC 沒有在等待 reveal,你可以開始新的發射。", - "No expiry": "永不過期" + "No expiry": "永不過期", + "Burned": "已銷毀", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣及透過銷毀交易銷毀的代幣。" } diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json index 6cd8a526..9d57c38e 100644 --- a/apps/web/src/locales/zh-tw.json +++ b/apps/web/src/locales/zh-tw.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "完成發射 {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "{asset} 銘刻的 commit 已廣播,但 reveal 尚未廣播。reveal 上鏈後發射才算成立;在此之前,commit 的 BTC 會停留在只有該 reveal 才能花費的輸出中。", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 銘刻的 commit 始終沒有上鏈,而它花費的一枚幣已被另一筆已確認的交易花掉,所以它永遠不會上鏈。它的 BTC 沒有在等待 reveal,你可以開始新的發射。", - "No expiry": "永不過期" + "No expiry": "永不過期", + "Burned": "已銷毀", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣和透過銷毀交易銷毀的代幣。" } diff --git a/apps/web/src/locales/zh.json b/apps/web/src/locales/zh.json index ebe25e2b..c3a7c438 100644 --- a/apps/web/src/locales/zh.json +++ b/apps/web/src/locales/zh.json @@ -1295,5 +1295,7 @@ "Finish launching {asset}": "完成发射 {asset}", "The inscription commit for {asset} is broadcast, but its reveal is not. The launch exists only once the reveal is on-chain, and until then the commit's BTC waits in an output only that reveal can spend.": "{asset} 铭刻的 commit 已广播,但 reveal 尚未广播。reveal 上链后发射才算成立;在此之前,commit 的 BTC 会停留在只有该 reveal 才能花费的输出中。", "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 铭刻的 commit 始终没有上链,而它花费的一枚币已被另一笔已确认的交易花掉,所以它永远不会上链。它的 BTC 没有在等待 reveal,你可以开始新的发射。", - "No expiry": "永不过期" + "No expiry": "永不过期", + "Burned": "已销毁", + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括发送到销毁地址的代币和通过销毁交易销毁的代币。" } diff --git a/tests/holders.test.ts b/tests/holders.test.ts index 7bd63b54..81b467cc 100644 --- a/tests/holders.test.ts +++ b/tests/holders.test.ts @@ -1,11 +1,25 @@ import { describe, expect, it } from "vitest"; import { + BURNED_ROW, + withBurnedRow, coalesceHolderBalances, currentHolderCount, includeFormerHolders, } from "@/lib/holders"; describe("holder balances", () => { + it("combines burns and destructions without counting the burn balance twice", () => { + const rows = withBurnedRow([{address:"alice",quantity:70n},{address:"burn",quantity:10n}], "burn", "30"); + expect(rows).toEqual([{address:"alice",quantity:70n},{address:BURNED_ROW,quantity:30n}]); + expect(currentHolderCount(rows)).toBe(1); + expect(rows.reduce((sum,row)=>sum+row.quantity,0n)).toBe(100n); + }); + it("shows destruction-only totals even without a burn-address balance", () => { + expect(withBurnedRow([], "burn", "123456789012345678")).toEqual([{address:BURNED_ROW,quantity:123456789012345678n}]); + }); + it("hides zero burns and does not mistake a reserved burn-address balance for a burn", () => { + expect(withBurnedRow([{address:"burn",quantity:10n}], "burn", "0")).toEqual([]); + }); it("counts positive owners while retaining sold-out rows", () => { const rows = coalesceHolderBalances([ { address: "alice", utxo: null, quantity: "50" },