aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-04 19:29:08 -0700
committerAdam Soltys <[email protected]>2026-07-04 19:29:08 -0700
commit1d9cac6ca92187718eafbd567562875d25fa772b (patch)
treec5ffaa9205d0236fda69ca798d73c33dd9c49a54 /src
parentcc2c05d2a1b7a9601eb3cb0df998483099e590cc (diff)
downloadcoinosv3-1d9cac6ca92187718eafbd567562875d25fa772b.tar.gz
coinosv3-1d9cac6ca92187718eafbd567562875d25fa772b.zip
Ark: recover from stale vtxo state
Match captaind's actual 'not spendable' error so a crashed or foreign spend fails the action instead of retrying forever, and reconcile locally-spendable vtxos against the server (GetVtxoStatus + ownership attestation) when an ark address lands in the send form. boardFee now treats absent proto3 fee fields as zero (Second's signet boards free). Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ark/board.js6
-rw-r--r--src/ark/manager.js24
-rw-r--r--src/ark/proto.js21
-rw-r--r--src/features/ark.js15
4 files changed, 61 insertions, 5 deletions
diff --git a/src/ark/board.js b/src/ark/board.js
index 3352823..f71c0b3 100644
--- a/src/ark/board.js
+++ b/src/ark/board.js
@@ -21,8 +21,10 @@ const u64le = (n) => {
return b;
};
-export const boardFee = (amountSat, fees) =>
- Math.max(fees.minFeeSat, fees.baseFeeSat + Math.floor((amountSat * fees.ppm) / 1_000_000));
+// Fee fields are absent when zero (proto3) — e.g. Second's signet server
+// boards for free — so every term defaults to 0.
+export const boardFee = (amountSat, fees = {}) =>
+ Math.max(fees.minFeeSat || 0, (fees.baseFeeSat || 0) + Math.floor((amountSat * (fees.ppm || 0)) / 1_000_000));
export const p2trAddress = (programXOnly, hrp = 'bcrt') =>
bech32m.encode(hrp, [1, ...bech32m.toWords(programXOnly)], 1023);
diff --git a/src/ark/manager.js b/src/ark/manager.js
index bb7eb5f..310bab5 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -21,6 +21,7 @@ import {
getArkInfo, handshake, encodeAddress, decodeAddress, blindMailboxId,
readMailbox, decodeVtxo, arkIdFromServerPubkey, GrpcError,
grpcStream, decodeMailboxMessage, mailboxRequestBytes,
+ getVtxoStatus, VTXO_STATE_SPENT,
} from './proto.js';
import {
buildArkoorSend, cosignWithServer, buildSignedVtxoBytes,
@@ -214,6 +215,27 @@ export class ArkManager {
this._streamAbort = null;
}
+ // Check every locally-spendable vtxo against the server's authoritative
+ // off-chain ledger and flip the ones it reports spent (state drift from the
+ // same seed active elsewhere, or a restored state snapshot). Trust flows one
+ // way — spendable -> spent — so a lying server can hide balance from the UI
+ // but never mint it, and the signed bytes stay held for unilateral exit.
+ async reconcile() {
+ const candidates = this.state.vtxos.filter((v) => v.state === 'spendable');
+ const states = await Promise.all(candidates.map((v) =>
+ getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey)
+ .catch(() => null))); // unreachable/erroring server changes nothing
+ let changed = false;
+ candidates.forEach((v, i) => {
+ if (states[i] !== VTXO_STATE_SPENT) return;
+ v.state = 'spent';
+ this._movement({ type: 'reconcile', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, detail: 'spent elsewhere (server vtxo status)' });
+ changed = true;
+ });
+ if (changed) this._save();
+ return changed;
+ }
+
async resumePending() {
for (const action of this.pendingActions()) {
try {
@@ -284,7 +306,7 @@ export class ArkManager {
try {
sigs = await cosignWithServer(this.arkUrl, build, { input, outputs, vtxoKeys: keys, serverPubkey: this.serverPub });
} catch (e) {
- if (e instanceof GrpcError && /already spent/i.test(e.message)) {
+ if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) {
// the input is gone (possibly a prior crashed attempt) — don't retry
inputRec.state = 'spent';
action.step = 'failed';
diff --git a/src/ark/proto.js b/src/ark/proto.js
index 728686b..1cbc0ef 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -363,7 +363,8 @@ export function decodeVtxo(bytes) {
export async function getArkInfo(ark) {
const data = await grpcCall(ark, 'bark_server.ArkService/GetArkInfo', new Uint8Array(0));
- const info = {};
+ // Fee schedules a server never sends (or sends all-zero) mean "free".
+ const info = { boardFees: {}, refreshFees: { baseFeeSat: 0, ppmExpiryTable: [] } };
for (const { field, value } of pbFields(data)) {
if (field === 1) info.network = td.decode(value);
if (field === 2) info.serverPubkey = hex.encode(value);
@@ -446,6 +447,24 @@ export function decodeMailboxMessage(value) {
return msg;
}
+// VtxoSpendState values from GetVtxoStatus (bark_server.proto).
+export const VTXO_STATE_SPENDABLE = 1;
+export const VTXO_STATE_SPENT = 2;
+
+const VTXO_STATUS_PREFIX = te.encode('Ark VTXO status query challenge '); // 32 bytes
+
+// Ask the server for a vtxo's authoritative spend state. The attestation is a
+// BIP340 signature with the vtxo's user key, so only the owner can query.
+export async function getVtxoStatus(ark, vtxoIdRaw /* 36B outpoint */, vtxoPrivkey) {
+ const attestation = schnorr.sign(sha256(concatBytes(VTXO_STATUS_PREFIX, vtxoIdRaw)), vtxoPrivkey);
+ const w = pbWriter();
+ w.bytesField(1, vtxoIdRaw);
+ w.bytesField(2, attestation);
+ const data = await grpcCall(ark, 'bark_server.ArkService/GetVtxoStatus', w.finish());
+ for (const { field, value } of pbFields(data)) if (field === 1) return Number(value);
+ return 0; // proto3 omits a zero enum: UNSPECIFIED
+}
+
// Build an authenticated MailboxRequest (shared by read + subscribe).
export function mailboxRequestBytes(mailbox, checkpoint = 0) {
const expiry = Math.floor(Date.now() / 1000) + 60;
diff --git a/src/features/ark.js b/src/features/ark.js
index 868192a..ca718ee 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -104,6 +104,17 @@ export function arkFeature(ctx) {
return ark.balance();
}
+ // An ark address in the send form signals a send is coming: verify our
+ // spendable vtxos against the server now, so a stale one (same seed active
+ // elsewhere, restored state) is dropped before coin selection instead of
+ // failing at cosign time. Throttled — this fires from the render path.
+ let arkReconciledAt = 0;
+ function maybeReconcile() {
+ if (Date.now() - arkReconciledAt < 30_000) return;
+ arkReconciledAt = Date.now();
+ connectArk().then((mgr) => mgr.reconcile()).catch(() => {});
+ }
+
function arkSendReview() {
if (ui.arkSent) {
return h(
@@ -408,7 +419,9 @@ export function arkFeature(ctx) {
isSendDest(a) { return isArkAddress(a) && arkAvailable(); },
hideSendControls(a) { return isArkAddress(a); },
sendFormNote(a) {
- return isArkAddress(a) ? h('div', { class: 'small faint' }, t('arkSendHint')) : null;
+ if (!isArkAddress(a)) return null;
+ maybeReconcile();
+ return h('div', { class: 'small faint' }, t('arkSendHint'));
},
interceptReview(s) {
if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) {