From 4131e5ee24c4891caffdd8f4ca903ee973d104f7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 13 Sep 2026 23:52:39 -0700 Subject: The renewal happens whether or not anyone opens the app. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every coin expires, and until now the only thing standing between a balance and its expiry was maybeAutoRefresh: it renews when the wallet is opened inside the last 96 blocks, roughly sixteen hours. Bark's own developers say the same premise fails on mobile — people don't reliably open a wallet at the right hour, which is why every mobile Ark client they know of has moved to delegated refreshes and why they are building server-driven renewal. captaind already has it, and ark.coinos.io has been carrying the column unused: SubmitRoundParticipation takes a scheduled_height, and the server holds that participation out of every round until the height arrives, then runs it with nobody online — the round tree branch is signed by the server, which is the whole point of a delegated refresh. The fee is priced at the height it will run at, so a standing order lands in the free bracket rather than today's. So each spendable coin now gets one registered 36 blocks before its expiry, six hourly rounds of slack. It is deliberately not an action: the coin stays spendable, the wallet never reads as busy, and spending it simply kills the order — the server drops a participation whose input is gone, and _driveScheduled drops ours. The claim comes later, whenever the wallet next opens: the mailbox breadcrumb we already post wakes it, and the leaf cosign and forfeit are the ordinary refresh path from there. Nothing about the input's expiry blocks that claim, which is what makes this worth having. A wallet that goes quiet for a month comes back to a coin already renewed and anchored in a fresh tree, instead of one the ASP has swept and would have to honor out of goodwill. One cycle per visit is all it can buy — the replacement's id doesn't exist yet, so its own backstop waits for the next time the app is opened. tools/ark/scheduled-refresh-test.js covers the five behaviours on regtest: the server holds the order until its height, runs it while the wallet is closed, the wallet claims on return, a spend voids the order, and a claim still works after the input has EXPIRED (the last one mines a coin's whole lifetime, so it also re-refreshes the funding wallet first — an expired vtxo can be renewed but not sent). Riding along: the expired-coin notice that was already sitting in the working tree, and manager-test's tampered-vtxo check, which had been reading base64 vtxo bytes as hex since they moved to IndexedDB. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019fMdLypdg5JaU7bdeUFF8v --- src/ark/refresh.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'src/ark/refresh.js') diff --git a/src/ark/refresh.js b/src/ark/refresh.js index 4bd74f2..6e4453d 100644 --- a/src/ark/refresh.js +++ b/src/ark/refresh.js @@ -311,7 +311,12 @@ export function participationAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { // mailboxId: when given, the server posts a recovery breadcrumb (the unlock // hash) to that mailbox once the round runs — any device of this wallet can // then claim the output even if the action that submitted it is lost. -export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId }) { +// scheduledHeight: when given, the server holds the participation out of +// every round until that block height and then runs it with nobody online +// (and prices the refresh fee at that height, not today's). Omitted, the +// participation joins the next round, which is what an interactive refresh +// wants. +export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId, scheduledHeight }) { const w = pbWriter(); for (const { vtxo, keys } of inputs) { const iv = pbWriter(); @@ -326,6 +331,7 @@ export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId w.bytesField(3, vr.finish()); } if (mailboxId) w.bytesField(4, mailboxId); + if (scheduledHeight) w.varintField(5, scheduledHeight); const resp = await grpcCall(ark, 'bark_server.ArkService/SubmitRoundParticipation', w.finish()); for (const { field, value } of pbFields(resp)) if (field === 1) return value; // unlock_hash throw new Error('no unlock hash in response'); -- cgit v1.2.3