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],
+ ]);
+ });
+});