diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 19:29:08 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 19:29:08 -0700 |
| commit | 1d9cac6ca92187718eafbd567562875d25fa772b (patch) | |
| tree | c5ffaa9205d0236fda69ca798d73c33dd9c49a54 /src/ark | |
| parent | cc2c05d2a1b7a9601eb3cb0df998483099e590cc (diff) | |
| download | coinosv3-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/ark')
| -rw-r--r-- | src/ark/board.js | 6 | ||||
| -rw-r--r-- | src/ark/manager.js | 24 | ||||
| -rw-r--r-- | src/ark/proto.js | 21 |
3 files changed, 47 insertions, 4 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; |
