diff --git a/apps/api/migrations/0040_nullable_order_expire_index.sql b/apps/api/migrations/0040_nullable_order_expire_index.sql new file mode 100644 index 00000000..d24588c9 --- /dev/null +++ b/apps/api/migrations/0040_nullable_order_expire_index.sql @@ -0,0 +1,47 @@ +-- Counterparty Core now returns open orders that never expire: expiration = 0 +-- and expire_index = NULL. 0022 declared expire_index NOT NULL, so one such +-- order failed its market's whole write batch and left that book stale. +-- +-- NULL now means "no expiry". SQLite cannot drop a NOT NULL constraint with +-- ALTER TABLE, so the table is rebuilt: identical to 0022 except for that one +-- column, every row copied as it is, and both of 0022's indexes recreated. No +-- table references orders and no trigger is defined on it, so nothing else +-- has to be carried across. + +DROP TABLE IF EXISTS orders_new; + +CREATE TABLE orders_new ( + tx_hash TEXT PRIMARY KEY, + tx_index INTEGER NOT NULL, + block_index INTEGER NOT NULL, + source TEXT NOT NULL, + asset TEXT NOT NULL, + side TEXT NOT NULL, + token_quantity TEXT NOT NULL, + xcp_quantity TEXT NOT NULL, + -- NULL: the order never expires (Core's expiration = 0). + expire_index INTEGER, + token_remaining TEXT NOT NULL, + xcp_remaining TEXT NOT NULL, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +INSERT INTO orders_new + (tx_hash, tx_index, block_index, source, asset, side, + token_quantity, xcp_quantity, expire_index, + token_remaining, xcp_remaining, status, updated_at) +SELECT tx_hash, tx_index, block_index, source, asset, side, + token_quantity, xcp_quantity, expire_index, + token_remaining, xcp_remaining, status, updated_at + FROM orders; + +DROP TABLE orders; + +ALTER TABLE orders_new RENAME TO orders; + +-- Dropped with the old table; recreated exactly as 0022 defined them. +CREATE INDEX idx_orders_recent ON orders(block_index DESC, tx_index DESC); + +CREATE INDEX idx_orders_open ON orders(block_index DESC, tx_index DESC) + WHERE status = 'open'; diff --git a/apps/api/src/indexer/orders.ts b/apps/api/src/indexer/orders.ts index 4ccb17f3..9d8d29c9 100644 --- a/apps/api/src/indexer/orders.ts +++ b/apps/api/src/indexer/orders.ts @@ -67,7 +67,8 @@ interface OrderRow { tokenRemaining: string; xcpRemaining: string; status: string; - expireIndex: number; + /** Null for an order that never expires. */ + expireIndex: number | null; } /** @@ -97,12 +98,26 @@ function toRows(asset: string, orders: CpOrder[]): OrderRow[] { tokenRemaining: String(buying ? o.get_remaining : o.give_remaining), xcpRemaining: String(buying ? o.give_remaining : o.get_remaining), status: o.status, - expireIndex: o.expire_index, + expireIndex: expireIndex(o), }); } return rows; } +/** + * The block an order expires at, or null when it never does. + * + * Core returns expiration = 0 with expire_index = null for an order that never + * expires. Should it ever omit expire_index for one that does expire, the + * block is still derivable from where the order was placed and how long it + * asked to stay open. + */ +function expireIndex(o: CpOrder): number | null { + if (typeof o.expire_index === "number" && Number.isFinite(o.expire_index)) return o.expire_index; + if (typeof o.expiration === "number" && o.expiration > 0) return o.block_index + o.expiration; + return null; +} + /** * A fingerprint of everything about this market's book that can change. * diff --git a/apps/api/src/integrations/counterparty.ts b/apps/api/src/integrations/counterparty.ts index 3626cfca..1c28b6a3 100644 --- a/apps/api/src/integrations/counterparty.ts +++ b/apps/api/src/integrations/counterparty.ts @@ -284,7 +284,10 @@ export interface CpOrder { get_asset: string; get_quantity: number | string; get_remaining: number | string; - expire_index: number; + /** Blocks the order stays open for; 0 means it never expires. */ + expiration: number; + /** The block it expires at, or null for an order that never expires. */ + expire_index: number | null; /** `open`, `filled`, `cancelled` or `expired`. Counterparty has no * "partially filled" status — that is an open order whose remaining is * below its original, and the tape derives it. */ diff --git a/apps/api/src/queries/activity.ts b/apps/api/src/queries/activity.ts index c7bda8fe..884ed6b0 100644 --- a/apps/api/src/queries/activity.ts +++ b/apps/api/src/queries/activity.ts @@ -210,7 +210,8 @@ export interface OrderRow { token_remaining: string; xcp_remaining: string; status: string; - expire_index: number; + /** Null for an order that never expires. */ + expire_index: number | null; divisible: number; } 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 5b9787fe..14b1db89 100644 --- a/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx +++ b/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx @@ -1070,7 +1070,7 @@ export function ActivityTabs({ ) : ( <> {num.percent(filled, { digits: 0 })} ·{" "} - {o!.expire_index === null ? "GTC" : num.commas(o!.expire_index)} + {o!.expire_index === null ? t("No expiry") : num.commas(o!.expire_index)} )} diff --git a/apps/web/src/app/[lang]/activity/_components/activity-view.tsx b/apps/web/src/app/[lang]/activity/_components/activity-view.tsx index cb278b76..820f4516 100644 --- a/apps/web/src/app/[lang]/activity/_components/activity-view.tsx +++ b/apps/web/src/app/[lang]/activity/_components/activity-view.tsx @@ -522,9 +522,11 @@ function OrderTape({ rows, height }: { rows: ActivityOrder[]; height?: number }) {r.state === "partial" ? t("{pct}% filled", { pct }) : r.state === "open" - ? height - ? t("{eta} left", { eta: blocksEta(r.expireBlock - height, t) }) - : "" + ? r.expireBlock === null + ? t("No expiry") + : height + ? t("{eta} left", { eta: blocksEta(r.expireBlock - height, t) }) + : "" : r.state === "filled" ? "" : pct > 0 diff --git a/apps/web/src/app/[lang]/profile/_components/orders-tab.tsx b/apps/web/src/app/[lang]/profile/_components/orders-tab.tsx index 0e137492..b85db013 100644 --- a/apps/web/src/app/[lang]/profile/_components/orders-tab.tsx +++ b/apps/web/src/app/[lang]/profile/_components/orders-tab.tsx @@ -94,7 +94,7 @@ export function OrdersTab({ )} {t("{pct}% filled", { pct: (filled * 100).toFixed(0) })} ·{" "} - {o.expire_index === null ? "GTC" : t("expires block {n}", { n: num.commas(o.expire_index) })} + {o.expire_index === null ? t("No expiry") : t("expires block {n}", { n: num.commas(o.expire_index) })} {canCancel && ( diff --git a/apps/web/src/lib/api/launchpad-api.ts b/apps/web/src/lib/api/launchpad-api.ts index 4bd2ca0e..e5257046 100644 --- a/apps/web/src/lib/api/launchpad-api.ts +++ b/apps/web/src/lib/api/launchpad-api.ts @@ -1511,7 +1511,8 @@ export interface ActivityOrder { side: "buy" | "sell"; state: OrderState; block: number; - expireBlock: number; + /** Null for an order that never expires. */ + expireBlock: number | null; /** Original size; the order's price is a statement about these. */ tokenQuantity: Raw; xcpQuantity: Raw; @@ -1705,7 +1706,7 @@ interface ApiActivityOrder { side: string; state: string; block_index: number; - expire_index: number; + expire_index: number | null; token_quantity: Raw; xcp_quantity: Raw; token_remaining: Raw; @@ -1777,7 +1778,7 @@ export async function fetchActivityOrders( // undefined. state: ORDER_STATES.find((s) => s === r.state) ?? "open", block: r.block_index, - expireBlock: r.expire_index, + expireBlock: typeof r.expire_index === "number" ? r.expire_index : null, tokenQuantity: r.token_quantity, xcpQuantity: r.xcp_quantity, tokenRemaining: r.token_remaining, diff --git a/apps/web/src/locales/_source.json b/apps/web/src/locales/_source.json index bb476cb4..bdc14cb1 100644 --- a/apps/web/src/locales/_source.json +++ b/apps/web/src/locales/_source.json @@ -2537,6 +2537,11 @@ "No closed positions yet.": [ "app/[lang]/profile/_components/history-tab.tsx" ], + "No expiry": [ + "app/[lang]/[asset]/_components/activity-tabs.tsx", + "app/[lang]/activity/_components/activity-view.tsx", + "app/[lang]/profile/_components/orders-tab.tsx" + ], "no history": [ "app/[lang]/[asset]/_components/activity-tabs.tsx" ], diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 3766eb83..1de43f0f 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "Si no se completa, se devuelve automáticamente todo el XCP de cada participante.", "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." + "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" } diff --git a/apps/web/src/locales/es.status.json b/apps/web/src/locales/es.status.json index dec0de47..a469d6d3 100644 --- a/apps/web/src/locales/es.status.json +++ b/apps/web/src/locales/es.status.json @@ -1281,6 +1281,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index 93b49db1..56ca7c42 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "En cas d’échec, la totalité des XCP de chaque participant est automatiquement remboursée.", "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." + "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" } diff --git a/apps/web/src/locales/fr.status.json b/apps/web/src/locales/fr.status.json index dec0de47..a469d6d3 100644 --- a/apps/web/src/locales/fr.status.json +++ b/apps/web/src/locales/fr.status.json @@ -1281,6 +1281,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/ja.json b/apps/web/src/locales/ja.json index bf9bb3ef..b1fbd279 100644 --- a/apps/web/src/locales/ja.json +++ b/apps/web/src/locales/ja.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "成立しなかった場合、すべての参加者に拠出したXCPが自動的に全額返還されます。", "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 はなく、新しいローンチを始められます。" + "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": "有効期限なし" } diff --git a/apps/web/src/locales/ja.status.json b/apps/web/src/locales/ja.status.json index ba319870..88fcf22d 100644 --- a/apps/web/src/locales/ja.status.json +++ b/apps/web/src/locales/ja.status.json @@ -1271,6 +1271,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/ko.json b/apps/web/src/locales/ko.json index 81bbc120..2fce4ab1 100644 --- a/apps/web/src/locales/ko.json +++ b/apps/web/src/locales/ko.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "출시가 실패하면 모든 민팅 참여자가 지불한 XCP 전액이 자동으로 환불됩니다.", "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는 없으며, 새 런칭을 시작할 수 있습니다." + "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": "만료 없음" } diff --git a/apps/web/src/locales/ko.status.json b/apps/web/src/locales/ko.status.json index 45bb37db..fcc20714 100644 --- a/apps/web/src/locales/ko.status.json +++ b/apps/web/src/locales/ko.status.json @@ -1281,6 +1281,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/pt.json b/apps/web/src/locales/pt.json index 988b4262..321c9dd1 100644 --- a/apps/web/src/locales/pt.json +++ b/apps/web/src/locales/pt.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "Se não atingir a meta, todo o XCP de cada participante é devolvido automaticamente.", "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." + "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" } diff --git a/apps/web/src/locales/pt.status.json b/apps/web/src/locales/pt.status.json index dec0de47..a469d6d3 100644 --- a/apps/web/src/locales/pt.status.json +++ b/apps/web/src/locales/pt.status.json @@ -1281,6 +1281,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json index a0b2d12e..60bac7f5 100644 --- a/apps/web/src/locales/ru.json +++ b/apps/web/src/locales/ru.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "Если запуск не состоится, все внесённые XCP автоматически возвращаются каждому участнику.", "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, и можно начать новый запуск." + "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": "Бессрочно" } diff --git a/apps/web/src/locales/ru.status.json b/apps/web/src/locales/ru.status.json index 3f35e7f9..faec0bfa 100644 --- a/apps/web/src/locales/ru.status.json +++ b/apps/web/src/locales/ru.status.json @@ -1283,6 +1283,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/uk.json b/apps/web/src/locales/uk.json index e8f947ec..e1e2cf41 100644 --- a/apps/web/src/locales/uk.json +++ b/apps/web/src/locales/uk.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "Якщо запуск не відбудеться, усі внесені XCP автоматично повертаються кожному учаснику.", "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, і можна почати новий запуск." + "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": "Безстроково" } diff --git a/apps/web/src/locales/uk.status.json b/apps/web/src/locales/uk.status.json index 3f35e7f9..faec0bfa 100644 --- a/apps/web/src/locales/uk.status.json +++ b/apps/web/src/locales/uk.status.json @@ -1283,6 +1283,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/zh-hk.json b/apps/web/src/locales/zh-hk.json index 5f5f157a..addd9f6d 100644 --- a/apps/web/src/locales/zh-hk.json +++ b/apps/web/src/locales/zh-hk.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "若發行失敗,每位鑄造參與者投入的全部 XCP 都會自動退還。", "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,你可以開始新的發射。" + "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": "永不過期" } diff --git a/apps/web/src/locales/zh-hk.status.json b/apps/web/src/locales/zh-hk.status.json index d420a38a..d17221e5 100644 --- a/apps/web/src/locales/zh-hk.status.json +++ b/apps/web/src/locales/zh-hk.status.json @@ -1279,6 +1279,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json index bfac5847..9506fb6e 100644 --- a/apps/web/src/locales/zh-tw.json +++ b/apps/web/src/locales/zh-tw.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "若發行失敗,每位鑄造參與者投入的全部 XCP 都會自動退還。", "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,你可以開始新的發射。" + "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": "永不過期" } diff --git a/apps/web/src/locales/zh-tw.status.json b/apps/web/src/locales/zh-tw.status.json index d420a38a..d17221e5 100644 --- a/apps/web/src/locales/zh-tw.status.json +++ b/apps/web/src/locales/zh-tw.status.json @@ -1279,6 +1279,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/apps/web/src/locales/zh.json b/apps/web/src/locales/zh.json index 9532bb49..95a9d403 100644 --- a/apps/web/src/locales/zh.json +++ b/apps/web/src/locales/zh.json @@ -1293,5 +1293,6 @@ "If it fails, every minter’s XCP is automatically refunded.": "若发行失败,每位铸造参与者投入的全部 XCP 都会自动退还。", "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,你可以开始新的发射。" + "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": "永不过期" } diff --git a/apps/web/src/locales/zh.status.json b/apps/web/src/locales/zh.status.json index 5a467541..c4c6a640 100644 --- a/apps/web/src/locales/zh.status.json +++ b/apps/web/src/locales/zh.status.json @@ -1278,6 +1278,7 @@ "If it fails, every minter’s XCP is automatically refunded.", "Finish launching {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.", - "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." + "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.", + "No expiry" ] } diff --git a/tests/order-no-expiry.test.ts b/tests/order-no-expiry.test.ts new file mode 100644 index 00000000..6114ead3 --- /dev/null +++ b/tests/order-no-expiry.test.ts @@ -0,0 +1,143 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiReplayDb } from "./helpers/api-replay-db"; + +/** + * Core returns open orders that never expire as expiration = 0 with + * expire_index = null. One such order used to fail its market's whole write + * (orders.expire_index was NOT NULL), leaving that book stale. + */ + +const MIGRATIONS = fileURLToPath(new URL("../apps/api/migrations", import.meta.url)); +const REBUILD = "0040_nullable_order_expire_index.sql"; +const ASSET = "KILLERAPP"; +const NEVER = "60919a4b02e8fe63af9d56ad9316f5dcc1087d771dbae45760d5e9d517b6a6de"; +const EXPIRING = "ab".repeat(32); + +function coreOrder(txHash: string, txIndex: number, expiration: number, expireIndex: number | null) { + return { + tx_hash: txHash, tx_index: txIndex, block_index: 968818, source: "1Source", + give_asset: "XCP", give_quantity: 100000000, give_remaining: 100000000, + get_asset: ASSET, get_quantity: 500000000000, get_remaining: 500000000000, + expiration, expire_index: expireIndex, status: "open", + }; +} + +const ORDER_COLUMNS = `tx_hash, tx_index, block_index, source, asset, side, token_quantity, + xcp_quantity, expire_index, token_remaining, xcp_remaining, status, updated_at`; + +describe(`migration ${REBUILD}`, () => { + it("makes expire_index nullable and keeps every row, column and index", () => { + const db = new DatabaseSync(":memory:"); + const files = readdirSync(MIGRATIONS).filter((f) => f.endsWith(".sql")).sort(); + expect(files.at(-1)).toBe(REBUILD); + for (const file of files.slice(0, -1)) db.exec(readFileSync(join(MIGRATIONS, file), "utf8")); + + const columnsBefore = db.prepare("PRAGMA table_info(orders)").all(); + const indexSql = () => db + .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'orders' AND sql IS NOT NULL ORDER BY name") + .all() + // Line endings follow the checkout, not the schema. + .map((i) => ({ name: i.name, sql: String(i.sql).replace(/\s+/g, " ") })); + const indexesBefore = indexSql(); + expect(indexesBefore.map((i) => i.name)).toEqual(["idx_orders_open", "idx_orders_recent"]); + + const insert = `INSERT INTO orders (${ORDER_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + const existing = [ + ["aa".repeat(32), 10, 968000, "1A", ASSET, "buy", "500000000000", "100000000", 969000, "0", "0", "filled", 1], + ["bb".repeat(32), 11, 968001, "1B", ASSET, "sell", "10000000000000000", "300000000", 969001, "10000000000000000", "300000000", "open", 2], + ]; + for (const row of existing) db.prepare(insert).run(...row); + expect(() => db.prepare(insert).run("cc".repeat(32), 12, 968002, "1C", ASSET, "buy", "1", "1", null, "1", "1", "open", 3)) + .toThrow(/NOT NULL constraint failed: orders.expire_index/); + + db.exec(readFileSync(join(MIGRATIONS, REBUILD), "utf8")); + + expect(db.prepare(`SELECT ${ORDER_COLUMNS} FROM orders ORDER BY tx_index`).all().map((r) => Object.values(r))) + .toEqual(existing); + const columnsAfter = db.prepare("PRAGMA table_info(orders)").all(); + expect(columnsAfter).toEqual(columnsBefore.map((c) => (c.name === "expire_index" ? { ...c, notnull: 0 } : c))); + expect(indexSql()).toEqual(indexesBefore); + expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'orders_new'").all()).toEqual([]); + + db.prepare(insert).run("cc".repeat(32), 12, 968002, "1C", ASSET, "buy", "1", "1", null, "1", "1", "open", 3); + expect(db.prepare("SELECT expire_index FROM orders WHERE tx_index = 12").get()).toEqual({ expire_index: null }); + // Every other column is still required. + expect(() => db.prepare(insert).run("dd".repeat(32), 13, 968003, "1D", ASSET, "buy", "1", "1", null, "1", "1", null, 4)) + .toThrow(/NOT NULL constraint failed: orders.status/); + db.close(); + }); +}); + +describe("an order that never expires", () => { + let database: ReturnType; + + beforeEach(() => { + vi.resetModules(); + database = apiReplayDb(); + const book = [ + coreOrder(NEVER, 3190001, 0, null), + coreOrder(EXPIRING, 3190000, 1000, 969818), + // Defensive: an expiring order without expire_index is still dated. + coreOrder("cd".repeat(32), 3189999, 144, null), + ]; + vi.stubGlobal("fetch", vi.fn(async (input: string) => { + const url = new URL(input); + if (url.pathname.endsWith(`/assets/${ASSET}/orders`)) return Response.json({ result: book, next_cursor: null }); + throw new Error(`No fixture for ${url}`); + })); + vi.stubGlobal("caches", { default: { match: async () => undefined, put: async () => undefined } }); + }); + afterEach(() => { database.raw.close(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + + it("is mirrored with a null expire_index and the market's digest is stored", async () => { + const { syncOrders } = await import("#api/indexer/orders"); + const result = await syncOrders(database.db, [ASSET]); + expect(result).toMatchObject({ markets_read: 1, markets_changed: 1, rows_written: 3, failed: 0 }); + expect(database.raw.prepare("SELECT tx_hash, expire_index, status FROM orders ORDER BY tx_index DESC").all()).toEqual([ + { tx_hash: NEVER, expire_index: null, status: "open" }, + { tx_hash: EXPIRING, expire_index: 969818, status: "open" }, + { tx_hash: "cd".repeat(32), expire_index: 968818 + 144, status: "open" }, + ]); + expect(database.raw.prepare("SELECT value FROM chain_state WHERE key = ?").get(`orders_digest:${ASSET}`)).toBeTruthy(); + + // The next tick finds nothing new and writes nothing. + expect(await syncOrders(database.db, [ASSET])).toMatchObject({ markets_changed: 0, rows_written: 0 }); + }); + + it("is served as open, in the live book, with expire_index null", async () => { + const { syncOrders } = await import("#api/indexer/orders"); + await syncOrders(database.db, [ASSET]); + const { activityRoute } = await import("#api/read/activity"); + const ctx = { waitUntil: () => {}, passThroughOnException: () => {}, props: {} } as unknown as ExecutionContext; + for (const live of [false, true]) { + const res = await activityRoute.request(`https://api.test/v2/activity/orders${live ? "?live=1" : ""}`, {}, { DB: database.db }, ctx); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: Array<{ tx_hash: string; state: string; expire_index: number | null }>; total: number }; + expect(body.total).toBe(3); + expect(body.result.find((o) => o.tx_hash === NEVER)).toMatchObject({ state: "open", expire_index: null }); + expect(body.result.find((o) => o.tx_hash === EXPIRING)).toMatchObject({ state: "open", expire_index: 969818 }); + } + }); + + it("reaches the web app as expireBlock null, never a number", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ + result: [ + { tx_hash: NEVER, asset: ASSET, source: "1Source", side: "buy", state: "open", block_index: 968818, expire_index: null, + token_quantity: "500000000000", xcp_quantity: "100000000", token_remaining: "500000000000", xcp_remaining: "100000000", filled: 0, divisible: 1 }, + { tx_hash: EXPIRING, asset: ASSET, source: "1Source", side: "buy", state: "open", block_index: 968818, expire_index: 969818, + token_quantity: "500000000000", xcp_quantity: "100000000", token_remaining: "500000000000", xcp_remaining: "100000000", filled: 0, divisible: 1 }, + ], + total: 2, + }))); + const { fetchActivityOrders } = await import("@/lib/api/launchpad-api"); + const page = await fetchActivityOrders(50, true); + expect(page?.rows.map((r) => [r.txHash, r.state, r.expireBlock])).toEqual([ + [NEVER, "open", null], + [EXPIRING, "open", 969818], + ]); + }); +});