Skip to content

Commit 1db52bb

Browse files
authored
Merge pull request #244 from sambabbage/test/storage-arcade-e2e
test(wallet-toolbox): validate and harden Storage Arcade throughput
2 parents 3e4035f + f097481 commit 1db52bb

6 files changed

Lines changed: 1177 additions & 34 deletions

File tree

e2e/storage/README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Production Storage + Arcade + Monitor E2E
2+
3+
This manual mainnet suite validates the complete production path:
4+
5+
1. authenticated BRC-100 reads and writes go to the target Storage server;
6+
2. Arcade is the first broadcaster and receives the shared callback token;
7+
3. no-send concurrency tests are reconciled through Storage with `sendWith`;
8+
4. the production Monitor receives Arcade SSE status events and acquires proofs;
9+
5. every tracked action must reach `completed` in Storage and have a merkle path in Arcade.
10+
11+
The suite provisions deterministic child identities with enough independent BRC-29 wallet-payment UTXOs for the configured load. When it creates a funding fan-out, it waits for that transaction to mine and complete in Storage before measuring writes. This prevents parent-propagation latency from being mislabeled as a throughput limit and does not rely on an external funding script.
12+
13+
Before it creates any new transaction, the suite also verifies that every Storage-reported spendable default-basket output belongs to a `completed` action and remains an actual network UTXO. It stops instead of spending when Storage and the network disagree. Use a dedicated, clean root key and a fresh `STORAGE_E2E_USER_OFFSET` for comparison runs.
14+
15+
## Safety gate
16+
17+
The suite is skipped unless `STORAGE_E2E_ALLOW_MAINNET=true`. It requires a funded test key and spends real sats. Use a bounded, dedicated test identity where possible; never commit or print its private key or the Arcade token.
18+
19+
## Run
20+
21+
```bash
22+
export STORAGE_E2E_ALLOW_MAINNET=true
23+
export STORAGE_E2E_ROOT_KEY=<funded-mainnet-private-key-hex>
24+
export STORAGE_E2E_ARCADE_TOKEN=<token-shared-with-production-monitor>
25+
export STORAGE_E2E_TARGET_URL=https://storage.babbage.systems
26+
export STORAGE_E2E_USER_COUNT=3
27+
export STORAGE_E2E_USER_OFFSET=0
28+
export STORAGE_E2E_USER_FUNDING_SATS=400
29+
export STORAGE_E2E_TX_COUNT=3
30+
export STORAGE_E2E_CEILING_BATCHES=2,4,8
31+
export STORAGE_E2E_LOAD_REPEATS=1
32+
export STORAGE_E2E_MULTI_USER_ROUNDS=1
33+
export STORAGE_E2E_EVIDENCE_FILE=/tmp/storage-e2e-evidence.json
34+
35+
cd packages/wallet/wallet-toolbox
36+
pnpm exec jest --runTestsByPath \
37+
src/services/__tests/StorageE2E.man.test.ts \
38+
--runInBand --verbose
39+
```
40+
41+
## Configuration
42+
43+
| Variable | Default | Meaning |
44+
|---|---:|---|
45+
| `STORAGE_E2E_TARGET_URL` | `https://storage.babbage.systems` | The only Storage backend used by authenticated cases. |
46+
| `STORAGE_E2E_ARCADE_URL` | mainnet Arcade | Arcade broadcaster and proof API. |
47+
| `STORAGE_E2E_USER_COUNT` | `3` | Independent derived identities. |
48+
| `STORAGE_E2E_USER_OFFSET` | `0` | Offset added to deterministic child derivation indexes; use a new offset to isolate a load run from prior wallet state. |
49+
| `STORAGE_E2E_TX_COUNT` | `3` | Transactions in each fixed-size write case. |
50+
| `STORAGE_E2E_OUTPUT_SATS` | `100` | Sats in each explicit test output. |
51+
| `STORAGE_E2E_USER_FUNDING_SATS` | `400` | Sats in each independent BRC-29 funding UTXO. |
52+
| `STORAGE_E2E_MULTI_USER_ROUNDS` | `1` | Full `createAction` rounds run concurrently across the derived identities. |
53+
| `STORAGE_E2E_CEILING_BATCHES` | `2,4,8` | Transaction counts for single-identity and sharded phase load. |
54+
| `STORAGE_E2E_LOAD_REPEATS` | `1` | Repetitions per phase-load batch size; use at least 3 for comparison runs. |
55+
| `STORAGE_E2E_PROOF_TIMEOUT_MS` | `2700000` | Maximum wait for mining and Monitor reconciliation. |
56+
| `STORAGE_E2E_PROOF_POLL_MS` | `30000` | Arcade and Storage polling interval. |
57+
| `STORAGE_E2E_EVIDENCE_FILE` | unset | Optional mode-0600 JSON evidence artifact. |
58+
59+
## What counts as passing
60+
61+
HTTP timing alone is not enough. A run passes only if every transaction created by setup and the write/SSE cases:
62+
63+
- is accepted through the Arcade-first service path;
64+
- later reports `MINED` or `IMMUTABLE` with a non-empty Arcade merkle path; and
65+
- is returned by the originating wallet's authenticated `listActions` call with status `completed`.
66+
67+
That final condition is the externally observable proof that Monitor updated production Storage. Operators may additionally correlate the emitted txids against Monitor logs and the `proven_tx_reqs`/`proven_txs` rows for deployment-level evidence.
68+
69+
## Throughput methodology
70+
71+
The suite reports several rates because there is no honest single “Storage TPS” number for this path:
72+
73+
| Metric | Timed boundary | What it isolates |
74+
|---|---|---|
75+
| Raw HTTPS req/s | unauthenticated `GET /` | ingress, TLS, and the Node HTTP server; not Storage work |
76+
| Authenticated read req/s | concurrent BRC-100 `listOutputs` | mutual-auth signing/verification plus Storage lookup/query work |
77+
| Full wallet write tx/s | `createAction` start through its response | client wallet work, two authenticated Storage phases, database work, and broadcaster latency |
78+
| Preparation tx/s | no-send `createAction` calls | wallet construction/signing and Storage persistence without network broadcast |
79+
| Arcade submission tx/s | one merged `postBeef` call through all per-tx Arcade HTTP responses | EF construction plus the broadcaster round trips; `Arcade.postBeef` caps independent concurrency at 4 and preserves parent-before-child order |
80+
| Storage reconciliation tx/s | concurrent per-identity `sendWith` RPCs | Storage batch lookup/verification, database state changes, and Storage's own broadcaster calls |
81+
| Submission-through-Storage tx/s | Arcade submission plus Storage reconciliation | externally submitted transactions accepted back into the Storage lifecycle |
82+
| Client end-to-end tx/s | preparation plus submission plus reconciliation | the rate one harness process achieved for the complete pre-proof path |
83+
84+
Each load size runs once by default to limit mainnet cost. For a comparison with less small-sample noise, use at least three repeats and preserve the JSON artifact:
85+
86+
```bash
87+
export STORAGE_E2E_USER_COUNT=8
88+
export STORAGE_E2E_USER_OFFSET=100
89+
export STORAGE_E2E_MULTI_USER_ROUNDS=3
90+
export STORAGE_E2E_CEILING_BATCHES=4,8,16
91+
export STORAGE_E2E_LOAD_REPEATS=3
92+
```
93+
94+
Increase only one dimension at a time, record client location and deployed image/version, and compare both single-identity and sharded results. A plateau in both modes points toward a shared service dependency. A sharded improvement with a flat single-identity rate points toward per-wallet state, authentication session, or per-user database serialization. Do not treat proof convergence as transaction-submission TPS: block cadence and Monitor polling dominate that elapsed time.
95+
96+
## Known bottleneck boundaries
97+
98+
- `Arcade.postBeef` must turn a merged BEEF into one EF request per txid. This branch uses a dependency-aware, bounded four-request worker pool: independent transactions can overlap, while chained transactions remain parent-before-child. The prior serial loop made even independent `sendWith` work pay one external round trip after another; naïvely parallelizing a chain causes Arcade to accept first and later reject children.
99+
- A full `createAction` is intentionally more expensive than an Arcade submission. It performs wallet coin selection/signing, authenticated Storage create/process RPCs, transactional database updates, BEEF verification, and broadcaster work.
100+
- Throughput batches intentionally use independent, mined UTXOs. Passing `noSendChange` creates a dependency chain; a child can reach Arcade before its parent has propagated even when HTTP requests are sent in order, so chained submission is a propagation-latency test rather than an independent-TPS test.
101+
- One identity has wallet and UTXO state that cannot be mutated arbitrarily in parallel. The single-identity load uses one dedicated derived wallet; the sharded case uses the remaining identities so their unsettled outputs do not contaminate each other.
102+
- Authentication and database work are absent from raw HTTP measurements. Compare raw, authenticated, and no-send phases before attributing a plateau to ingress or Arcade.
103+
- An Arcade `REJECTED` SSE event is not necessarily terminal. Production has emitted `REJECTED` and later `SEEN_MULTIPLE_NODES` for the same txid. Releasing that transaction's inputs on the first event creates false double-spend pressure and invalidates later TPS samples; the Monitor must retain the reservation until proof/rebroadcast processing reaches an authoritative result.
104+
- Storage's default output query includes unproven outputs. The harness preflight therefore correlates output parents with completed actions and asks the configured UTXO service to confirm each outpoint before funding or load; a raw wallet balance alone is not a safe load-test prerequisite.
105+
- Deployment topology and database pool size can become ceilings outside this repository. Capture the replica count, process model, CPU/memory use, and connection-pool configuration alongside production load results before tuning them.

packages/wallet/wallet-toolbox/src/monitor/tasks/TaskArcSSE.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ArcSSEClient, ArcSSEEvent } from '../../services/providers/ArcSSEClient
55
import { Monitor } from '../Monitor'
66
import { WalletMonitorTask } from './WalletMonitorTask'
77
import { Services } from '../../services/Services'
8+
import { TaskUnFail } from './TaskUnFail'
89

910
/**
1011
* Monitor task that receives transaction status updates from Arcade via SSE
@@ -116,7 +117,18 @@ export class TaskArcadeSSE extends WalletMonitorTask {
116117
for (const reqApi of reqs) {
117118
const req = new EntityProvenTxReq(reqApi)
118119

119-
if (ProvenTxReqTerminalStatus.includes(req.status)) {
120+
const acceptedByNetwork = [
121+
'SENT_TO_NETWORK',
122+
'ACCEPTED_BY_NETWORK',
123+
'SEEN_ON_NETWORK',
124+
'SEEN_MULTIPLE_NODES'
125+
].includes(event.txStatus)
126+
const confirmedByNetwork = ['MINED', 'IMMUTABLE'].includes(event.txStatus)
127+
// Arcade can emit REJECTED before another node accepts the same transaction.
128+
// Permit a later positive network observation to heal requests that an older
129+
// Monitor version prematurely marked invalid.
130+
const canRecoverRejectedRequest = req.status === 'invalid' && (acceptedByNetwork || confirmedByNetwork)
131+
if (ProvenTxReqTerminalStatus.includes(req.status) && !canRecoverRejectedRequest) {
120132
log += ` req ${req.id} already terminal: ${req.status}\n`
121133
continue
122134
}
@@ -130,25 +142,37 @@ export class TaskArcadeSSE extends WalletMonitorTask {
130142
switch (event.txStatus) {
131143
case 'SENT_TO_NETWORK':
132144
case 'ACCEPTED_BY_NETWORK':
133-
case 'SEEN_ON_NETWORK': {
134-
if (['unsent', 'sending', 'callback'].includes(req.status)) {
145+
case 'SEEN_ON_NETWORK':
146+
case 'SEEN_MULTIPLE_NODES': {
147+
if (['unsent', 'sending', 'callback', 'invalid'].includes(req.status)) {
148+
const wasInvalid = req.status === 'invalid'
135149
req.status = 'unmined'
150+
if (wasInvalid) req.attempts = 0
136151
req.wasBroadcast = true
137152
req.addHistoryNote(note)
138-
await req.updateStorageDynamicProperties(this.storage)
139-
const ids = req.notify.transactionIds
140-
if (ids != null) {
153+
if (wasInvalid) {
154+
// Restore transaction state, re-reserve wallet inputs, and verify
155+
// output spendability using the established unfail repair path.
156+
log += await new TaskUnFail(this.monitor).unfailReq(req, 4)
157+
} else if (req.notify.transactionIds != null) {
141158
await this.storage.runAsStorageProvider(async sp => {
142-
await sp.updateTransactionsStatus(ids, 'unproven')
159+
await sp.updateTransactionsStatus(req.notify.transactionIds ?? [], 'unproven')
143160
})
144161
}
162+
await req.updateStorageDynamicProperties(this.storage)
145163
log += ` req ${req.id} => unmined\n`
146164
}
147165
break
148166
}
149167

150168
case 'MINED':
151169
case 'IMMUTABLE': {
170+
if (req.status === 'invalid') {
171+
req.status = 'unmined'
172+
req.attempts = 0
173+
req.wasBroadcast = true
174+
log += await new TaskUnFail(this.monitor).unfailReq(req, 4)
175+
}
152176
req.addHistoryNote(note)
153177
await req.updateStorageDynamicProperties(this.storage)
154178
log += await this.fetchProofFromServices(req)
@@ -170,16 +194,13 @@ export class TaskArcadeSSE extends WalletMonitorTask {
170194
}
171195

172196
case 'REJECTED': {
173-
req.status = 'invalid'
197+
// REJECTED is not a final consensus result: production Arcade has
198+
// subsequently reported SEEN_MULTIPLE_NODES for the same txid. Keep
199+
// wallet inputs reserved while proof/rebroadcast processing resolves
200+
// the transaction authoritatively.
174201
req.addHistoryNote(note)
175202
await req.updateStorageDynamicProperties(this.storage)
176-
const ids = req.notify.transactionIds
177-
if (ids != null) {
178-
await this.storage.runAsStorageProvider(async sp => {
179-
await sp.updateTransactionsStatus(ids, 'failed')
180-
})
181-
}
182-
log += ` req ${req.id} => invalid\n`
203+
log += ` req ${req.id} rejection recorded; awaiting resolution\n`
183204
break
184205
}
185206

@@ -237,6 +258,7 @@ export class TaskArcadeSSE extends WalletMonitorTask {
237258
req.apiHistory = r.history
238259
req.provenTxId = r.provenTxId
239260
req.notified = true
261+
await req.updateStorageDynamicProperties(this.storage)
240262

241263
this.monitor.callOnProvenTransaction({
242264
txid,

packages/wallet/wallet-toolbox/src/monitor/tasks/__tests/TaskArcSSE.test.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { TaskArcadeSSE } from '../TaskArcSSE'
22
import { ArcSSEEvent } from '../../../services/providers/ArcSSEClient'
33
import { EntityProvenTx } from '../../../storage/schema/entities'
4+
import { TaskUnFail } from '../TaskUnFail'
45

56
// ── Fake EventSource ─────────────────────────────────────────────────────────
67

@@ -62,7 +63,8 @@ function makeStorageWithReqs (reqApis: any[]): any {
6263
return {
6364
isStorageProvider: jest.fn().mockReturnValue(false),
6465
findProvenTxReqs: jest.fn().mockResolvedValue(reqApis),
65-
runAsStorageProvider: jest.fn(async (fn: any) => fn(sp))
66+
runAsStorageProvider: jest.fn(async (fn: any) => fn(sp)),
67+
sp
6668
}
6769
}
6870

@@ -257,14 +259,49 @@ describe('TaskArcadeSSE', () => {
257259
expect(log).toContain('=> unmined')
258260
})
259261

262+
test('SEEN_MULTIPLE_NODES is accepted without an unhandled warning', async () => {
263+
const { log } = await runWithStatus('SEEN_MULTIPLE_NODES', 'unmined')
264+
expect(log).not.toContain('unhandled status')
265+
})
266+
260267
test('DOUBLE_SPEND_ATTEMPTED sets req to doubleSpend', async () => {
261268
const { log } = await runWithStatus('DOUBLE_SPEND_ATTEMPTED', 'unmined')
262269
expect(log).toContain('=> doubleSpend')
263270
})
264271

265-
test('REJECTED sets req to invalid', async () => {
266-
const { log } = await runWithStatus('REJECTED', 'unmined')
267-
expect(log).toContain('=> invalid')
272+
test('REJECTED records the event without releasing wallet inputs', async () => {
273+
const { log, monitor } = await runWithStatus('REJECTED', 'unmined')
274+
expect(log).toContain('rejection recorded; awaiting resolution')
275+
expect(monitor.storage.sp.updateProvenTxReqDynamics).toHaveBeenCalledWith(
276+
1,
277+
expect.objectContaining({ status: 'unmined' }),
278+
undefined
279+
)
280+
expect(monitor.storage.sp.updateTransactionsStatus).not.toHaveBeenCalled()
281+
})
282+
283+
test('SEEN_MULTIPLE_NODES recovers a req previously marked invalid', async () => {
284+
const unfailReq = jest.spyOn(TaskUnFail.prototype, 'unfailReq').mockResolvedValue('inputs re-reserved\n')
285+
const { log, monitor } = await runWithStatus('SEEN_MULTIPLE_NODES', 'invalid')
286+
expect(log).toContain('=> unmined')
287+
expect(unfailReq).toHaveBeenCalledWith(expect.anything(), 4)
288+
expect(monitor.storage.sp.updateProvenTxReqDynamics).toHaveBeenCalledWith(
289+
1,
290+
expect.objectContaining({ status: 'unmined', wasBroadcast: true }),
291+
undefined
292+
)
293+
})
294+
295+
test('MINED recovers an invalid req before fetching its proof', async () => {
296+
const unfailReq = jest.spyOn(TaskUnFail.prototype, 'unfailReq').mockResolvedValue('inputs re-reserved\n')
297+
const { log, monitor } = await runWithStatus('MINED', 'invalid')
298+
expect(log).not.toContain('already terminal')
299+
expect(unfailReq).toHaveBeenCalledWith(expect.anything(), 4)
300+
expect(monitor.storage.sp.updateProvenTxReqDynamics).toHaveBeenCalledWith(
301+
1,
302+
expect.objectContaining({ status: 'unmined', wasBroadcast: true }),
303+
undefined
304+
)
268305
})
269306

270307
test('unknown status produces unhandled log entry', async () => {
@@ -273,8 +310,9 @@ describe('TaskArcadeSSE', () => {
273310
})
274311

275312
test('does not process already-terminal reqs', async () => {
276-
// ProvenTxReqTerminalStatus = ['completed', 'invalid', 'doubleSpend']
277-
const terminalStatuses = ['completed', 'invalid', 'doubleSpend']
313+
// A positive network event may recover invalid; the other terminal
314+
// statuses remain immutable.
315+
const terminalStatuses = ['completed', 'doubleSpend']
278316
for (const s of terminalStatuses) {
279317
const { log } = await runWithStatus('MINED', s)
280318
expect(log).toContain(`already terminal: ${s}`)
@@ -313,6 +351,11 @@ describe('TaskArcadeSSE', () => {
313351
expect(getMerklePath).toHaveBeenCalledWith(reqApi.txid)
314352
expect(fromReq).toHaveBeenCalledWith(expect.anything(), proof, false, expect.any(Number))
315353
expect(storage.runAsStorageProvider).toHaveBeenCalled()
354+
expect(storage.sp.updateProvenTxReqDynamics).toHaveBeenLastCalledWith(
355+
reqApi.provenTxReqId,
356+
expect.objectContaining({ notified: true }),
357+
undefined
358+
)
316359
expect(monitor.callOnProvenTransaction).toHaveBeenCalledWith(expect.objectContaining({
317360
txid: reqApi.txid,
318361
txIndex: 7,

0 commit comments

Comments
 (0)