Skip to content

Commit 26ae9b8

Browse files
Calgooonclaude
andcommitted
fix: e2e scenario sat leaks + sweep via HTTP API (WIP)
- Scenario 11: add abort after nosend test (was locking A's main UTXO) - Scenario 10: B internalizes concurrent sends (were lost on chain) - Scenario 7: note signAction returns raw tx not BEEF (can't internalize) - Sweep: use HTTP API for full balance across all baskets - FUNDER_DIR: support env var override Known remaining: scenario 7 loses 3K (signAction limitation), e2e harness needs full offline audit before next run. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b8e8bc commit 26ae9b8

4 files changed

Lines changed: 45 additions & 26 deletions

File tree

tests/e2e/lib/setup.js

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -82,49 +82,52 @@ async function fundWallet(wallet, satoshis) {
8282
* as possible. This prevents losing sats when a scenario creates unspendable outputs.
8383
*/
8484
async function sweepToFunder(wallet) {
85-
const balance = parseInt(
86-
execSync(`cd "${wallet.dir}" && "${BSV}" balance`, { encoding: 'utf-8' }).trim(),
87-
);
85+
// Use HTTP API for full balance (covers all baskets)
86+
const balance = await wallet.client.balance();
8887

8988
if (balance < 600) {
9089
console.log(` ${wallet.client.name}: ${balance} sats (dust, skipping sweep)`);
9190
return 0;
9291
}
9392

94-
const funderAddr = execSync(
95-
`cd "${FUNDER_DIR}" && "${BSV}" address`, { encoding: 'utf-8' },
96-
).trim();
93+
// Get funder's receiving key via HTTP API
94+
const funderClient = new WalletClient(FUNDER_PORT, 'funder');
95+
const funderKey = await funderClient.getPublicKey(
96+
[2, '3241645161d8'], 'SfKxPIJNgdI= NaGLC6fMH50=', ANYONE_KEY, true,
97+
);
98+
const funderScript = buildP2PKH(funderKey.publicKey);
9799

98-
// Try full sweep first, then halve on failure to recover what we can
99100
let sweepAmount = balance - 300; // leave room for fee
100101
let swept = 0;
101102

102103
for (let attempt = 0; attempt < 4 && sweepAmount >= 600; attempt++) {
103104
try {
104-
const rawResult = execSync(
105-
`cd "${wallet.dir}" && "${BSV}" --json send "${funderAddr}" ${sweepAmount}`,
106-
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
107-
).trim();
108-
109-
const parsed = JSON.parse(extractJson(rawResult));
110-
111-
// Funder internalizes directly
112-
execSync(
113-
`cd "${FUNDER_DIR}" && "${BSV}" fund "${parsed.beef}" --vout 0`,
114-
{ stdio: ['pipe', 'pipe', 'pipe'] },
105+
const result = await wallet.client.createAction(
106+
[{ lockingScript: funderScript, satoshis: sweepAmount, outputDescription: 'sweep to funder' }],
107+
`sweep ${sweepAmount} sats back to funder`,
115108
);
116109

110+
// Funder internalizes the BEEF
111+
await funderClient.internalizeAction(result.tx, [{
112+
outputIndex: 0,
113+
protocol: 'wallet payment',
114+
paymentRemittance: {
115+
derivationPrefix: 'SfKxPIJNgdI=',
116+
derivationSuffix: 'NaGLC6fMH50=',
117+
senderIdentityKey: ANYONE_KEY,
118+
},
119+
}], `sweep from ${wallet.client.name}`);
120+
117121
console.log(` ${wallet.client.name}: swept ${sweepAmount} sats back to funder`);
118122
swept += sweepAmount;
119123
break;
120124
} catch (e) {
121125
if (attempt < 3) {
122-
// Halve the amount and retry — some UTXOs may be unspendable
123126
const prev = sweepAmount;
124127
sweepAmount = Math.floor(sweepAmount / 2);
125-
console.log(` ${wallet.client.name}: sweep ${prev} failed, retrying with ${sweepAmount}`);
128+
console.log(` ${wallet.client.name}: sweep ${prev} failed (${e.message.slice(0, 60)}), retrying with ${sweepAmount}`);
126129
} else {
127-
console.log(` WARNING: ${wallet.client.name}: could not sweep (${sweepAmount} sats stuck)`);
130+
console.log(` WARNING: ${wallet.client.name}: could not sweep (${sweepAmount} sats stuck: ${e.message.slice(0, 80)})`);
128131
}
129132
}
130133
}

tests/e2e/scenarios/07-unsigned-action.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ module.exports = {
4949
: signed.txid;
5050
assert(txidHex.length === 64, `txid hex must be 64 chars, got ${txidHex.length}`);
5151

52+
// Note: signAction returns raw tx, not BEEF — can't internalize in B.
53+
// The 3K sats go to B's address on chain but B can't track them.
54+
// This is a known limitation of the deferred signing flow.
55+
5256
// WoC audit (quick check, don't block on chained unconfirmed)
5357
let wocOk = false;
5458
try {

tests/e2e/scenarios/10-concurrent-sends.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ module.exports = {
3535
outputDescription: `Concurrent send #${i + 1} of ${concurrency}`,
3636
tags: ['e2e-concurrent'],
3737
}], `E2E.4: concurrent #${i + 1}`)
38-
.then(r => ({ status: 'ok', txid: r.txid, index: i }))
38+
.then(r => ({ status: 'ok', txid: r.txid, tx: r.tx, index: i }))
3939
.catch(e => ({ status: 'error', error: e.message, index: i }))
4040
);
4141
}
@@ -65,11 +65,19 @@ module.exports = {
6565
assert(txids.size === concurrency,
6666
`All ${concurrency} txids must be unique, got ${txids.size}`);
6767

68-
// B internalizes all (using direct BEEF from responses)
69-
let internalized = 0;
68+
// B internalizes all (so sats can be swept back)
7069
for (const r of succeeded) {
71-
// Re-fetch the full result to get tx bytes (our concurrent results may not have full data)
72-
// Actually the results should already have tx bytes from createAction
70+
if (r.tx && r.tx.length > 0) {
71+
await walletB.client.internalizeAction(r.tx, [{
72+
outputIndex: 0,
73+
protocol: 'wallet payment',
74+
paymentRemittance: {
75+
derivationPrefix: 'SfKxPIJNgdI=',
76+
derivationSuffix: 'NaGLC6fMH50=',
77+
senderIdentityKey: ANYONE_KEY,
78+
},
79+
}], `internalize concurrent send #${r.index + 1}`);
80+
}
7381
}
7482

7583
// Check balances

tests/e2e/scenarios/11-nosend-flow.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ module.exports = {
5757
);
5858
assert(internalized.accepted === true, 'B must accept the noSend tx');
5959

60+
// Abort the nosend tx to release A's locked UTXOs — don't leave the wallet dirty
61+
const nosendRef = result.reference || result.txid;
62+
await walletA.client.post('abortAction', { reference: nosendRef });
63+
6064
return { txid: result.txid, wocConfirmed: 'n/a (noSend)', sats: amount };
6165
},
6266
};

0 commit comments

Comments
 (0)