aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-14 20:12:03 -0700
committerAdam Soltys <[email protected]>2026-08-14 20:12:03 -0700
commit222391a31361e42db713d1737d5a6e15cf8d5ed2 (patch)
tree4bf06a03b11de7ac898b578971372ca7f843e6f9 /src
parent4bd22c0c45ae35388b2a24e673bedcde5df77961 (diff)
downloadcoinosv3-222391a31361e42db713d1737d5a6e15cf8d5ed2.tar.gz
coinosv3-222391a31361e42db713d1737d5a6e15cf8d5ed2.zip
A send covers itself from as many coins as it needs: one atomic package cosign spends them all, the recipient receives several vtxos, and "consolidate with refresh() first" retires from the vocabulary
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js119
-rw-r--r--src/ark/send.js22
2 files changed, 102 insertions, 39 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 143242a..53e965d 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -24,7 +24,7 @@ import {
getVtxoStatus, VTXO_STATE_SPENT,
} from './proto.js';
import {
- buildArkoorSend, cosignWithServer, buildAllSignedVtxos,
+ buildArkoorSend, cosignWithServer, cosignPackageWithServer, buildAllSignedVtxos,
registerVtxoTransactions, postArkoorMessage, txid,
genUserNonces, cosignPartBytes, combineCosign,
} from './send.js';
@@ -528,6 +528,27 @@ export class ArkManager {
return candidates[0];
}
+ // Cover `amountSat` with as few coins as possible: the smallest single vtxo
+ // that covers it, else largest-first until the sum does. A balance scattered
+ // by many small receives stays spendable — the package cosign spends all
+ // the inputs atomically and the recipient just receives several vtxos.
+ _selectInputs(amountSat) {
+ const spendable = this.state.vtxos.filter((v) => v.state === 'spendable');
+ const single = spendable
+ .filter((v) => v.amountSat >= amountSat)
+ .sort((a, b) => a.amountSat - b.amountSat)[0];
+ if (single) return [single];
+ const picked = [];
+ let sum = 0;
+ for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) {
+ picked.push(v); sum += v.amountSat;
+ if (sum >= amountSat) break;
+ }
+ if (sum < amountSat) throw new Error('insufficient ark balance');
+ if (picked.length > 24) throw new Error('balance is spread over too many coins for one send');
+ return picked;
+ }
+
async send(addrString, amountSat) {
const dest = decodeAddress(addrString);
if (dest.arkId !== hex.encode(arkIdFromServerPubkey(this.serverPub))) {
@@ -536,47 +557,69 @@ export class ArkManager {
const mailboxDelivery = dest.delivery.find((d) => d.type === 1);
if (!mailboxDelivery) throw new Error('address has no mailbox delivery mechanism');
- const input = this._selectInput(amountSat);
- const changeSat = input.amountSat - amountSat;
+ const inputs = this._selectInputs(amountSat);
+ // Each input funds its own arkoor: the first n-1 are consumed whole, the
+ // last carries the remainder plus any change.
+ let remaining = amountSat;
+ const parts = inputs.map((v) => {
+ const destSat = Math.min(remaining, v.amountSat);
+ remaining -= destSat;
+ const changeSat = v.amountSat - destSat;
+ return {
+ inputId: v.id, destSat, changeSat,
+ changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null,
+ };
+ });
const action = {
id: `send-${Date.now()}`, type: 'send', step: 'created',
- inputId: input.id, amountSat, destAddress: addrString,
+ parts, amountSat, destAddress: addrString,
destPubkey: dest.userPubkey, destBlindedId: mailboxDelivery.data,
- changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, changeSat,
};
- input.state = 'pending';
+ for (const v of inputs) v.state = 'pending';
this.state.actions.push(action);
this._save();
await this._driveSend(action);
return action.id;
}
- _sendOutputs(action) {
- const outputs = [{ amountSat: action.amountSat, userPubkey: hex.decode(action.destPubkey) }];
- if (action.changeSat > 0) {
- outputs.push({ amountSat: action.changeSat, userPubkey: this._key(action.changeIndex).pubkey });
- }
- return outputs;
+ // pre-multi-input actions carried the single part in flat fields
+ _sendParts(action) {
+ return action.parts || [{
+ inputId: action.inputId, destSat: action.amountSat,
+ changeSat: action.changeSat || 0, changeIndex: action.changeIndex,
+ }];
}
async _driveSend(action) {
if (action.step === 'created') {
- const inputRec = this._vtxo(action.inputId);
- const input = this._decoded(inputRec);
- const keys = this._keyForVtxo(inputRec);
- const outputs = this._sendOutputs(action);
- await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]);
- const build = buildArkoorSend({ input, outputs, serverPubkey: this.serverPub, vtxoKeys: keys });
- let sigs;
+ const parts = this._sendParts(action);
+ const inputRecs = parts.map((p) => this._vtxo(p.inputId));
+ const decoded = inputRecs.map((r) => this._decoded(r));
+ const keysList = inputRecs.map((r) => this._keyForVtxo(r));
+ await registerVtxoTransactions(this.arkUrl, decoded.map((d) => d._raw.bytes));
+ const builds = parts.map((p, i) => buildArkoorSend({
+ input: decoded[i],
+ outputs: [
+ { amountSat: p.destSat, userPubkey: hex.decode(action.destPubkey) },
+ ...(p.changeSat > 0 ? [{ amountSat: p.changeSat, userPubkey: this._key(p.changeIndex).pubkey }] : []),
+ ],
+ serverPubkey: this.serverPub, vtxoKeys: keysList[i],
+ }));
+ let sigsList;
try {
- sigs = await cosignWithServer(this.arkUrl, build, { input, outputs, vtxoKeys: keys, serverPubkey: this.serverPub });
+ // one atomic package: the server signs every part or none
+ sigsList = await cosignPackageWithServer(this.arkUrl,
+ builds.map((b, i) => ({ build: b, input: decoded[i], vtxoKeys: keysList[i] })),
+ this.serverPub);
} catch (e) {
const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message);
if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) {
- // "already spent": the input is gone (possibly a prior crashed
- // attempt). Status 3: the server rejected the cosign outright, so
- // the input is untouched and returns to spendable. Neither retries.
- inputRec.state = spent ? 'spent' : 'spendable';
+ // Status 3: the server rejected the whole package — nothing was
+ // spent, everything returns to spendable. "already spent" names ONE
+ // stale input but not which: optimistically free them all and let
+ // reconcile() flip the truly-gone one back to spent.
+ for (const r of inputRecs) r.state = 'spendable';
+ if (spent) this.reconcile().catch(() => {});
action.step = 'failed';
action.error = e.message;
this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message });
@@ -585,17 +628,25 @@ export class ArkManager {
}
throw e;
}
- // the input is spent server-side from this moment: persist immediately.
- // Dust padding may SPLIT the destination or change across two vtxos —
+ // the inputs are spent server-side from this moment: persist immediately.
+ // Dust padding may SPLIT a destination or change across two vtxos —
// classify results by policy key rather than by output index.
- const all = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub })
- .map((bytes) => ({ bytes, v: decodeVtxo(bytes) }));
- const dest = all.filter((x) => x.v.policy.userPubkey === action.destPubkey);
- const change = all.filter((x) => x.v.policy.userPubkey !== action.destPubkey);
- action.destBytesList = dest.map((x) => hex.encode(x.bytes));
- action.changeBytesList = change.map((x) => hex.encode(x.bytes));
- for (const x of change) this._addVtxo(x.v, x.bytes, action.changeIndex, 'pending');
- inputRec.state = 'spent';
+ const destBytesList = [];
+ const changeBytesList = [];
+ for (let i = 0; i < parts.length; i++) {
+ const all = buildAllSignedVtxos({ input: decoded[i], build: builds[i], finalSigs: sigsList[i], serverPubkey: this.serverPub })
+ .map((bytes) => ({ bytes, v: decodeVtxo(bytes) }));
+ for (const x of all) {
+ if (x.v.policy.userPubkey === action.destPubkey) destBytesList.push(hex.encode(x.bytes));
+ else {
+ changeBytesList.push(hex.encode(x.bytes));
+ this._addVtxo(x.v, x.bytes, parts[i].changeIndex, 'pending');
+ }
+ }
+ inputRecs[i].state = 'spent';
+ }
+ action.destBytesList = destBytesList;
+ action.changeBytesList = changeBytesList;
action.step = 'cosigned';
this._save();
}
diff --git a/src/ark/send.js b/src/ark/send.js
index 0e63c7c..f0e471a 100644
--- a/src/ark/send.js
+++ b/src/ark/send.js
@@ -461,13 +461,25 @@ export function combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubke
// full MuSig2 ceremony over the ASP's RequestArkoorCosign
export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubkey, preimage }) {
- const nonces = genUserNonces(build, vtxoKeys);
+ const [sigs] = await cosignPackageWithServer(ark, [{ build, input, vtxoKeys, preimage }], serverPubkey);
+ return sigs;
+}
+
+// Same ceremony for a PACKAGE of arkoor parts (one per input vtxo) in a single
+// atomic request — the server cosigns all or none, so a multi-input send can't
+// half-spend. Returns one finalSigs array per part, in order.
+export async function cosignPackageWithServer(ark, parts, serverPubkey) {
+ const noncesList = parts.map((p) => genUserNonces(p.build, p.vtxoKeys));
const req = pbWriter();
- req.bytesField(1, cosignPartBytes({ build, input, vtxoKeys, nonces, preimage }));
+ parts.forEach((p, i) => req.bytesField(1, cosignPartBytes({
+ build: p.build, input: p.input, vtxoKeys: p.vtxoKeys, nonces: noncesList[i], preimage: p.preimage,
+ })));
const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish());
- const [serverResp] = parsePackageCosignResponse(respBytes);
- if (!serverResp) throw new Error('empty cosign response');
- return combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubkey });
+ const resps = parsePackageCosignResponse(respBytes);
+ if (resps.length !== parts.length) throw new Error('bad cosign response count');
+ return parts.map((p, i) => combineCosign({
+ build: p.build, nonces: noncesList[i], serverResp: resps[i], vtxoKeys: p.vtxoKeys, serverPubkey,
+ }));
}
// assemble the final signed Vtxo<Full> bytes for output `idx`, where idx