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 * -----------
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.
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 */
4660import Arweave from 'arweave'
61+ import { createData , ArweaveSigner } from '@dha-team/arbundles'
4762import { readFileSync , readdirSync } from 'node:fs'
4863import { existsSync } from 'node:fs'
4964import { join } from 'node:path'
@@ -57,6 +72,8 @@ const FORCE = has('--force')
5772const ALLOW_UNANCHORED = has ( '--allow-unanchored' )
5873const WAIT_S = Number ( flag ( '--wait' ) ?? 900 )
5974const 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
6178if ( ! 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
7693function 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}
0 commit comments