Skip to content

Commit bbbcdbb

Browse files
committed
Publish snapshots through the node's own bundler instead of direct L1
1 parent 569fabc commit bbbcdbb

4 files changed

Lines changed: 153 additions & 105 deletions

File tree

ao/docs/durability-and-recovery.md

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ the dependency D22 exists to remove.
7777
| script | what it does |
7878
|---|---|
7979
| `scripts/snapshot-state.ts <env>` | capture a consistent, anchored snapshot per contract |
80-
| `scripts/publish-snapshot.ts <dir>` | publish snapshots as direct L1 transactions (dry run by default) |
80+
| `scripts/publish-snapshot.ts <dir>` | publish snapshots through the node's own `~bundler@1.0` (dry run by default) |
8181
| `scripts/verify-snapshot.ts <dir>` | verify payload, anchor, chain continuity and message retrievability |
8282
| `scripts/recover-from-arweave.ts <pid>` | reconstruct state + ordered history from Arweave alone |
8383

@@ -121,29 +121,34 @@ and the snapshot never touches the write path.
121121
`periodic { cron = "@daily", prohibit_overlap = true }`. Overlap prohibition plus the
122122
(process, slot) dedupe below means a double-post is unreachable even if a run wedges.
123123

124-
Snapshots go to Arweave as **direct L1 transactions**, not through a bundler - including not
125-
through our own `~bundler@1.0`. Three reasons, in order of weight:
126-
127-
1. **Our `~bundler@1.0` is broken.** It signs, prices and mines the bundle transaction, then
128-
every chunk POST returns `400 data_root_not_found`: `building_proofs` computes a `data_size`
129-
that does not match the bundle it just posted a header for, so the merkle root describes a
130-
payload that does not exist. Structural in the `SignedTX -> structured@1.0 -> tx@1.0` round
131-
trip between `post_tx` and `build_proofs`, not payload-specific - snapshots would fail the
132-
same way while still being paid for.
133-
2. **A bundled item is only queryable by tag if a gateway chooses to unbundle it.** A direct L1
134-
transaction is indexed natively with its tags. Recovery finds snapshots by
135-
`tag process=<pid>, type=state-snapshot`, so bundling would make durability depend on gateway
136-
policy for no benefit.
137-
3. **The saving is 0.348%.** Measured 2026-08-25: three separate transactions cost 0.0205867 AR,
138-
one bundle of the same bytes 0.0205150 AR. Arweave prices per byte; bundling amortises many
139-
small items, and three ~1 MiB blobs are the opposite shape.
140-
141-
If `~bundler@1.0` is fixed, revisit (2) before (3) - the unbundling dependency is the real
142-
objection, not the fee.
143-
144-
Measured 2026-08-25:
145-
146-
| contract | state | gzipped | L1 cost |
124+
Snapshots go to Arweave through **the node's own `~bundler@1.0`**, the same path every
125+
scheduled message and assignment already takes since D24. The point is to operate, fund and
126+
monitor one upload mechanism rather than two.
127+
128+
This was decided against direct L1, which the tooling originally used, and the argument that had
129+
to be answered was real: recovery finds snapshots by GraphQL **tag query**
130+
(`tag process=<pid>, type=state-snapshot`), and a bundled data item is indexed only if a gateway
131+
chooses to unbundle it, whereas an L1 transaction is indexed natively. Verified against live on
132+
2026-08-28 and the concern does not hold in practice - items inside our own node-signed bundles
133+
are tag-discoverable on arweave.net, and `transaction(id:)` reports a block height for them,
134+
which is what the publisher's settlement wait depends on.
135+
136+
Two consequences are worth stating plainly rather than discovering later:
137+
138+
- **The node pays.** `PUBLISH_JWK` signs the data item and holds no AR. The wallet to keep
139+
funded is the node's, which already pays for every assignment it publishes.
140+
- **Acceptance is not settlement.** The bundler batches on an idle flush, then mines, then the
141+
gateway indexes - minutes, not seconds. A run that ends with items accepted but not yet
142+
indexed is normal and exits 0. Confirming they actually landed is D25's job, and it has to
143+
cover snapshots and not only assignments, because snapshot durability now shares a failure
144+
domain with the node's upload queue.
145+
146+
The fee was never the deciding factor either way: bundling the same bytes saves 0.348%
147+
(measured 2026-08-25, 0.0205867 AR as three transactions against 0.0205150 AR as one bundle).
148+
149+
Measured 2026-08-25 - now an estimate of what the node pays, not a direct charge:
150+
151+
| contract | state | gzipped | cost |
147152
|---|---|---|---|
148153
| operator-registry | 0.99 MiB | 0.36 MiB | 0.0059 AR |
149154
| relay-rewards | 4.02 MiB | 0.96 MiB | 0.0117 AR |
@@ -161,8 +166,10 @@ If a published snapshot exists for the same (process, slot) but its `state-sha25
161166
that is not a duplicate - one slot produced two different states, which is a correctness problem.
162167
The publisher reports it and exits non-zero rather than skipping silently. `--force` overrides.
163168

164-
`PUBLISH_JWK` is an **Arweave JWK** that signs and pays. It is not the EVM key used by
165-
`publish-module.ts`, which signs ANS-104 items for a bundler and holds no AR.
169+
`PUBLISH_JWK` is an **Arweave JWK** that signs the data item and pays nothing. `BUNDLER` says
170+
which node takes the upload and defaults to `http://$SNAPSHOT_HOST`, the in-cluster address the
171+
periodic job resolves from Consul. That default matters: `~bundler@1.0` is refused at the edge
172+
on stage and live, so the upload only works from inside the cluster.
166173

167174
### Verifying
168175

ao/scripts/publish-snapshot.ts

Lines changed: 72 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
/**
2-
* D22 - publish state snapshots to Arweave as DIRECT L1 transactions.
2+
* D22 - publish state snapshots to Arweave through the node's own ~bundler@1.0.
33
*
4-
* Why L1 and not a bundler
5-
* ------------------------
6-
* Bundling exists to amortise many small items into one transaction. A snapshot is a
7-
* single ~1 MiB blob, so bundling buys nothing and costs us three problems:
8-
* - up.arweave.net is Forward Research infrastructure, and getting our durability
9-
* path off it is the entire point of WS-6;
10-
* - its 5 MiB ceiling is a POLICY limit we are already close to (live relay-rewards
11-
* is 4.02 MiB uncompressed and grows with fingerprints);
12-
* - our own ~bundler@1.0 is blocked on a proof/header size mismatch.
13-
* A direct L1 transaction has none of those. Measured 2026-08-25, a full round of all
14-
* three live contracts is ~1.54 MiB gzipped and costs 0.0205 AR.
4+
* Why our own bundler and not direct L1
5+
* -------------------------------------
6+
* WS-6 exists to get our durability path off Forward Research infrastructure, and D24
7+
* closed that: all three nodes sign and pay for their own bundles. Every scheduled message
8+
* and assignment already reaches Arweave that way, so publishing snapshots through the same
9+
* path leaves ONE upload mechanism to operate, fund and monitor instead of two.
10+
*
11+
* The objection this had to clear is that recovery finds snapshots by GraphQL TAG QUERY
12+
* (recover-from-arweave.ts), and a bundled data item is indexed only if a gateway chooses to
13+
* unbundle it. Verified against live 2026-08-28: items inside our own node-signed bundles ARE
14+
* tag-discoverable on arweave.net, and `transaction(id:)` reports a block height for them,
15+
* which is what the settlement wait below depends on.
16+
*
17+
* Consequences of the handoff, all real:
18+
* - the NODE pays, from its own wallet. This signer needs no AR at all.
19+
* - acceptance is not settlement. The bundler batches on an idle flush, then mines, then the
20+
* gateway indexes - minutes, not seconds. A run that ends PENDING is normal, not failure.
21+
* - snapshot durability now shares a failure domain with the node's own upload queue. That is
22+
* the trade taken for a single mechanism, and it is exactly why D25 must cover snapshots
23+
* too, not only assignments.
1524
*
1625
* Idempotency
1726
* -----------
@@ -27,7 +36,7 @@
2736
*
2837
* Safety
2938
* ------
30-
* Dry-run is the DEFAULT. Posting requires --confirm and spends real AR. Publishing an
39+
* Dry-run is the DEFAULT. Posting requires --confirm and spends the NODE's AR. Publishing an
3140
* UNANCHORED snapshot is refused: a snapshot with no anchor assignment leaves the
3241
* published chain rootless, which is the exact defect D22 exists to close, so paying to
3342
* store one would buy a false sense of durability.
@@ -40,10 +49,16 @@
4049
* PUBLISH_JWK=<json> bun run scripts/publish-snapshot.ts <dir> --confirm
4150
*
4251
* Env:
43-
* PUBLISH_JWK Arweave JWK (JSON, or a path to one). Signs AND PAYS. Required for --confirm.
44-
* GATEWAY gateway + peer for posting and verification (default https://arweave.net).
52+
* PUBLISH_JWK Arweave JWK (JSON, or a path to one). SIGNS the data item and nothing else -
53+
* it does not pay, so it needs no balance. Required for --confirm.
54+
* BUNDLER base URL of the node whose ~bundler@1.0 takes the upload. Defaults to
55+
* http://$SNAPSHOT_HOST, which is what the periodic job resolves from Consul.
56+
* The route is p4-exempt and refused at the edge, so this must be an
57+
* in-cluster address, never the public host.
58+
* GATEWAY gateway for dedupe, size pricing and settlement checks.
4559
*/
4660
import Arweave from 'arweave'
61+
import { createData, ArweaveSigner } from '@dha-team/arbundles'
4762
import { readFileSync, readdirSync } from 'node:fs'
4863
import { existsSync } from 'node:fs'
4964
import { join } from 'node:path'
@@ -57,6 +72,8 @@ const FORCE = has('--force')
5772
const ALLOW_UNANCHORED = has('--allow-unanchored')
5873
const WAIT_S = Number(flag('--wait') ?? 900)
5974
const GATEWAY = (process.env.GATEWAY || 'https://arweave.net').replace(/\/$/, '')
75+
const BUNDLER = (process.env.BUNDLER
76+
|| (process.env.SNAPSHOT_HOST ? `http://${process.env.SNAPSHOT_HOST}` : '')).replace(/\/$/, '')
6077

6178
if (!DIR) {
6279
console.error('usage: bun run scripts/publish-snapshot.ts <snapshotDir> [--confirm] [--wait 900]')
@@ -75,7 +92,7 @@ const ar = (winston: string) => (Number(winston) / 1e12).toFixed(8)
7592

7693
function loadJwk (): any {
7794
const raw = process.env.PUBLISH_JWK
78-
if (!raw) throw new Error('PUBLISH_JWK is not set - required to sign and pay for an L1 transaction')
95+
if (!raw) throw new Error('PUBLISH_JWK is not set - required to sign the data item')
7996
const text = existsSync(raw) ? readFileSync(raw, 'utf8') : raw
8097
const jwk = JSON.parse(text)
8198
if (!jwk.n || !jwk.d) throw new Error('PUBLISH_JWK does not look like an Arweave JWK')
@@ -171,38 +188,53 @@ async function main () {
171188
console.log('\nnothing to publish - every snapshot is already on chain at its slot')
172189
return
173190
}
174-
console.log(`\n TOTAL ${ar(totalWinston.toString())} AR for ${priced.length} transaction(s)`)
191+
console.log(`\n TOTAL ${ar(totalWinston.toString())} AR for ${priced.length} item(s) - ESTIMATE of what the NODE pays`)
175192

176193
if (!CONFIRM) {
177194
console.log('\nDRY RUN - nothing posted. Re-run with --confirm (and PUBLISH_JWK set) to publish.')
178195
return
179196
}
180197

198+
if (!BUNDLER) {
199+
throw new Error('BUNDLER (or SNAPSHOT_HOST) is not set - required to know where to upload')
200+
}
181201
const jwk = loadJwk()
202+
const signer = new ArweaveSigner(jwk)
182203
const addr = await arweave.wallets.jwkToAddress(jwk)
183-
const balance = await arweave.wallets.getBalance(addr)
184-
console.log(`\n signer ${addr}`)
185-
console.log(` balance ${ar(balance)} AR`)
186-
if (BigInt(balance) < totalWinston) {
187-
throw new Error(`insufficient balance: need ${ar(totalWinston.toString())} AR, have ${ar(balance)} AR`)
188-
}
204+
console.log(`\n signer ${addr} (signs the item; the node pays for the bundle)`)
205+
console.log(` bundler ${BUNDLER}`)
189206

190207
const published: { contract: string, slot: string, id: string }[] = []
191208
for (const { item } of priced) {
192-
const tx = await arweave.createTransaction({ data: item.data }, jwk)
193-
for (const [k, v] of Object.entries(item.meta.tags)) tx.addTag(k, String(v))
194-
await arweave.transactions.sign(tx, jwk)
195-
196-
const uploader = await arweave.transactions.getUploader(tx)
197-
while (!uploader.isComplete) {
198-
await uploader.uploadChunk()
199-
process.stdout.write(`\r ${item.meta.tags.contract} upload ${uploader.pctComplete}% (${uploader.uploadedChunks}/${uploader.totalChunks}) `)
209+
// The snapshot's tags are already lowercase and unique, which is what lets dev_codec_ans104
210+
// re-encode the stored item bit-exact for signature verification on later reads.
211+
const di = createData(item.data, signer, {
212+
tags: Object.entries(item.meta.tags).map(([name, value]) => ({ name, value: String(value) })),
213+
})
214+
await di.sign(signer)
215+
216+
// `Accept: application/json` is REQUIRED. Without it the node answers the POST with the
217+
// Hyperbuddy HTML UI and HTTP 200, which reads as success and is not.
218+
const res = await fetch(`${BUNDLER}/~bundler@1.0/tx`, {
219+
method: 'POST',
220+
headers: {
221+
'Content-Type': 'application/ans104',
222+
'codec-device': 'ans104@1.0',
223+
'Accept': 'application/json',
224+
},
225+
body: di.getRaw(),
226+
signal: AbortSignal.timeout(300_000),
227+
})
228+
const body = (await res.text()).replace(/\s+/g, ' ')
229+
if (!res.ok || !body.includes('"id"')) {
230+
throw new Error(`bundler REFUSED ${item.meta.tags.contract} - HTTP ${res.status}: ${body.slice(0, 200)}`)
200231
}
201-
console.log(`\n ${item.meta.tags.contract} posted ${tx.id}`)
202-
published.push({ contract: item.meta.tags.contract, slot: item.meta.tags.slot, id: tx.id })
232+
console.log(` ${item.meta.tags.contract} accepted ${di.id} (queued - NOT yet on chain)`)
233+
published.push({ contract: item.meta.tags.contract, slot: item.meta.tags.slot, id: di.id })
203234
}
204235

205236
console.log(`\nwaiting up to ${WAIT_S}s for GraphQL settlement (a 200 from the data endpoint is NOT settlement)`)
237+
console.log('the bundler batches on an idle flush, then mines, then the gateway indexes - minutes')
206238
const deadline = Date.now() + WAIT_S * 1000
207239
const pending = new Set(published.map(p => p.id))
208240
while (pending.size && Date.now() < deadline) {
@@ -215,9 +247,14 @@ async function main () {
215247
console.log(` ${p.contract.padEnd(18)} slot=${String(p.slot).padEnd(6)} ${p.id} ${pending.has(p.id) ? 'PENDING' : 'settled'}`)
216248
}
217249
if (pending.size) {
218-
console.log(`\n${pending.size} transaction(s) not yet indexed. Re-check with:`)
250+
// Every item here was ACCEPTED by the bundler; the node owns landing it from this point.
251+
// Exiting non-zero would mark a routine slow flush as a failed job and train the operator
252+
// to ignore it. Settlement is D25's job, not this one's - it has to watch for the case
253+
// where an accepted item never lands, which is the 2026-08-27 failure class.
254+
console.log(`\n${pending.size} item(s) accepted but not yet indexed - NORMAL for a bundled upload.`)
255+
console.log('Confirm later with:')
219256
for (const id of pending) console.log(` bun run scripts/verify-snapshot.ts --published ${id}`)
220-
process.exit(1)
257+
return
221258
}
222259
console.log('\nVerify each with: bun run scripts/verify-snapshot.ts --published <id>')
223260
}

operations/ao/publish-snapshot-live.hcl

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,26 @@
1111
# both on p4's non-chargable routes, so this signer needs NO faff allow-list entry, and the
1212
# snapshot never touches the write path.
1313
#
14-
# --- Why direct L1 and not the node's ~bundler@1.0 ------------------------------------------
15-
# Three reasons, in order of weight:
14+
# --- Why the node's own ~bundler@1.0 and not direct L1 --------------------------------------
15+
# One upload mechanism, not two. D24 closed self-bundling on all three nodes, so every scheduled
16+
# message and assignment already reaches Arweave through ~bundler@1.0. Publishing snapshots the
17+
# same way leaves a single path to operate, fund and monitor.
1618
#
17-
# 1. Our own ~bundler@1.0 is BROKEN. It signs, prices and mines the bundle transaction, then
18-
# every chunk POST returns 400 data_root_not_found: `building_proofs` computes a data_size
19-
# that does not match the bundle it just posted a header for, so the merkle root describes
20-
# a payload that does not exist. That is structural in the SignedTX -> structured@1.0 ->
21-
# tx@1.0 round trip between post_tx and build_proofs, not payload-specific, so snapshots
22-
# would fail exactly the same way - while still being PAID FOR.
23-
# 2. A bundled item is only queryable by tag if a gateway chooses to UNBUNDLE it. A direct L1
24-
# transaction is indexed natively with its tags. Recovery finds snapshots by
25-
# `tag process=<pid>, type=state-snapshot`, so bundling would make the durability
26-
# mechanism depend on gateway policy for no benefit.
27-
# 3. The saving is 0.348%. Measured 2026-08-25: three separate transactions cost 0.0205867 AR,
28-
# one bundle of the same bytes costs 0.0205150 AR. Arweave prices per byte; bundling exists
29-
# to amortise MANY SMALL items, and three ~1 MiB blobs are the opposite shape.
19+
# The objection this had to clear: recovery finds snapshots by GraphQL TAG QUERY, and a bundled
20+
# data item is indexed only if a gateway chooses to UNBUNDLE it. Verified against live on
21+
# 2026-08-28 - items inside our own node-signed bundles ARE tag-discoverable on arweave.net, and
22+
# `transaction(id:)` reports a block height for them, which is what the settlement wait uses.
3023
#
31-
# If ~bundler@1.0 is ever fixed, revisit (2) before (3) - the unbundling dependency is the real
32-
# objection, not the fee.
24+
# What the handoff costs, and it is not nothing: snapshot durability now shares a failure domain
25+
# with the node's own upload queue, and acceptance by the bundler is NOT settlement on chain. A
26+
# run can legitimately end with items accepted but not yet indexed, and it exits 0 when it does.
27+
# That is exactly why D25's publishing-reliability monitoring has to cover snapshots too.
3328
#
3429
# --- Cost and safety ------------------------------------------------------------------------
35-
# WARNING: this SPENDS AR from PUBLISH_JWK - a real L1 transaction per contract. Keep it funded.
36-
# Measured 2026-08-25: 0.0206 AR for all three live contracts.
30+
# WARNING: this spends the NODE's AR, not PUBLISH_JWK's - the node signs and pays for the bundle
31+
# that carries the snapshot. PUBLISH_JWK signs the data item only and needs NO balance. The
32+
# thing to keep funded is the NODE wallet, which also pays for every assignment it publishes.
33+
# Estimated at 2026-08-25 prices: 0.0206 AR for all three live contracts.
3734
#
3835
# Publishing is IDEMPOTENT on (process, slot). A contract that has not advanced a slot since its
3936
# last published snapshot is SKIPPED, not re-posted - live operator-registry sits at slot 8 for
@@ -115,9 +112,14 @@ job "publish-snapshot-live" {
115112
# SNAPSHOT_HOST and PUBLISH_JWK are here, not in the env block: an env block does not run
116113
# through consul-template, so neither a service lookup nor a Vault read works there.
117114
#
118-
# PUBLISH_JWK is a DEDICATED Arweave JWK that signs and pays. Not the node's wallet - the
119-
# node's identity key stays in its own Vault path and is never used to publish - and not the
120-
# EVM key publish-module.ts uses, which signs ANS-104 items for a bundler and holds no AR.
115+
# PUBLISH_JWK is a DEDICATED Arweave JWK that SIGNS the snapshot data item and nothing else.
116+
# It holds no AR and needs none: the node pays for the bundle. It is deliberately not the
117+
# node's own key - the node's identity stays in its own Vault path and never signs a payload
118+
# - so a snapshot stays attributable to the publisher rather than to the scheduler.
119+
#
120+
# BUNDLER is not set here: publish-snapshot.ts defaults it to http://$SNAPSHOT_HOST, which is
121+
# the in-cluster address resolved just below. That matters - `~bundler@1.0` is refused at the
122+
# edge on this env, so the upload only works from inside the cluster.
121123
template {
122124
destination = "secrets/keys.env"
123125
env = true

0 commit comments

Comments
 (0)