@@ -288,14 +288,29 @@ for (const env of targets) {
288288 } ) , 'deploy-wallets is non-empty and EIP-55, excluding the node self-entry (spawns are impossible without it)' ,
289289 `${ dwEvm . length } entries${ dwSelf . length ? ' + node self-entry' : '' } ` )
290290
291- // Assert the PAIRING rather than each half. A node pointed at its own bundler without the
292- // self-entry silently refuses its own uploads; the self-entry without the loopback target is
293- // a widening that buys nothing. Either alone is a misconfiguration.
291+ // Assert the PAIRING rather than each half: a loopback bundler needs the route carve-out to
292+ // let the node's own upload through, and a carve-out without a loopback target is a widening
293+ // that buys nothing. Either alone is a misconfiguration.
294+ //
295+ // ⚠️ The pairing used to be loopback <-> a node SELF-ENTRY in `deploy-wallets`, and that never
296+ // worked: the node signs its uploads on the nested item, never on the envelope, so p4 sees no
297+ // signer and no wallet list can match. Shipping it stalled publishing on stage and live and
298+ // cost 8 slots. The self-entry is now redundant and should be ABSENT — it granted the node
299+ // identity slot-consuming and spawn rights for nothing.
294300 const bundlerTarget = ( await info ( host ) ) ?. [ 'bundler-ans104' ]
295301 const selfBundling = typeof bundlerTarget === 'string' && / 1 2 7 \. 0 \. 0 \. 1 | l o c a l h o s t / . test ( bundlerTarget )
296- check ( selfBundling === ( dwSelf . length === 1 ) ,
297- 'self-bundling configured coherently (loopback bundler <-> node self-entry in deploy-wallets)' ,
298- `bundler=${ bundlerTarget ?? '(unset)' } selfEntry=${ dwSelf . length } ` )
302+ // Read here rather than reusing the `bundlerRoutes` computed further down: that one lives
303+ // after this block, and a forward reference would be `undefined` at run time rather than a
304+ // compile error.
305+ const carveOuts = ( await listOf ( host , 'p4-non-chargable-routes' ) )
306+ . map ( r => r ?. template )
307+ . filter ( r => typeof r === 'string' && / b u n d l e r / i. test ( r ) )
308+ check ( selfBundling === ( carveOuts . length > 0 ) ,
309+ 'self-bundling configured coherently (loopback bundler <-> ~bundler@1.0 route carve-out)' ,
310+ `bundler=${ bundlerTarget ?? '(unset)' } carveOut=${ carveOuts . length } ` )
311+ check ( dwSelf . length === 0 ,
312+ 'deploy-wallets carries NO node self-entry (it never admitted the upload and grants slots + spawn)' ,
313+ dwSelf . length ? String ( dwSelf [ 0 ] ) : 'none' )
299314 } else {
300315 check ( p4 ?. [ 'pricing-device' ] === 'faff@1.0' && p4 ?. [ 'ledger-device' ] === 'faff@1.0' ,
301316 'final hook is p4 with faff pricing + ledger devices' ,
@@ -333,7 +348,13 @@ for (const env of targets) {
333348
334349 // --- native: p4 carve-outs ---------------------------------------------
335350 const routes = ( await listOf ( host , 'p4-non-chargable-routes' ) ) . map ( r => r ?. template )
336- check ( routes . length === 7 , 'p4-non-chargable-routes has exactly 7 entries' , `got ${ routes . length } ` )
351+ // 7 base entries, plus the bundler carve-out on an environment that self-bundles. The carve-out
352+ // is legitimate ONLY where the edge refuses `~bundler@1.0` outright — see the paired assertion
353+ // further down, which is the one that actually protects the wallet.
354+ const bundlerRoutes = routes . filter ( r => typeof r === 'string' && / b u n d l e r / i. test ( r ) )
355+ check ( routes . length === 7 + bundlerRoutes . length ,
356+ `p4-non-chargable-routes has exactly ${ 7 + bundlerRoutes . length } entries (7 base + ${ bundlerRoutes . length } bundler)` ,
357+ `got ${ routes . length } ` )
337358 // `.every()` is true for an empty list, so each assertion below is paired with a
338359 // length guard — otherwise a container that failed to parse reads as a clean pass.
339360 check ( routes . length > 0 && routes . every ( t => typeof t === 'string' && t . startsWith ( '^/' ) ) ,
@@ -622,8 +643,14 @@ for (const env of targets) {
622643 // Treat any 2xx as acceptance regardless of body, so a future response-shape change
623644 // cannot quietly turn this into a pass.
624645 const accepted = res . status >= 200 && res . status < 300
646+ // ⚠️ WHAT THIS ACTUALLY ASSERTS depends on the node. Through a real edge that refuses
647+ // `~bundler@1.0` the request never reaches p4, so a pass here is evidence about the EDGE and
648+ // says nothing about the pricing device. On a node with no edge it is evidence about p4. It
649+ // is a genuine check either way — the property is "a stranger cannot make us spend AR" — but
650+ // do not read a pass as proof that p4 gates the bundler. It usually does not; on stage and
651+ // live the carve-out means it deliberately does not.
625652 check ( ! accepted ,
626- 'bundler REFUSES an item from a non-allow-listed signer (acceptance would spend our AR )' ,
653+ 'a stranger CANNOT get an item bundled (edge or p4, whichever is the control here )' ,
627654 `HTTP ${ res . status } ` )
628655
629656 // `verify_message` rejects `unsigned_item` before anything is queued or metered. This is
@@ -641,12 +668,31 @@ for (const env of targets) {
641668 `HTTP ${ unsignedBundle . status } ` )
642669 }
643670
644- // A p4 carve-out for the bundler would exempt it from charging entirely, i.e. remove the
645- // only gate in front of a spending endpoint. Nothing should ever put it here, so assert
646- // the absence rather than trusting review to catch it.
647- check ( ! routes . some ( r => typeof r === 'string' && / b u n d l e r / i. test ( r ) ) ,
648- 'no p4-non-chargable carve-out for ~bundler@1.0 (a carve-out would ungate spending)' ,
649- routes . filter ( r => typeof r === 'string' && / b u n d l e r / i. test ( r ) ) . join ( ' ' ) || 'none' )
671+ // THE bundler invariant, and it is CONDITIONAL — an earlier version of this check asserted
672+ // the carve-out was never present, full stop, which is right for an open edge and wrong for a
673+ // closed one.
674+ //
675+ // `~bundler@1.0` accepts items this node pays an L1 reward to bundle, so exactly one control
676+ // must stand in front of it. p4 cannot be that control on a node that self-bundles: the node
677+ // signs its uploads on the nested item, never on the envelope (`hb_http:post` does not commit
678+ // what it sends), so the gate sees an unsigned request and would have to admit the route for
679+ // self-bundling to work at all. p4 is also the wrong layer for the actual risk, which is
680+ // VOLUME rather than identity — a large body is parsed and its nested item deserialized
681+ // before any pricing device runs.
682+ //
683+ // So the rule is a PAIR, and this asserts the pair rather than either half:
684+ // carve-out present -> the edge MUST refuse ~bundler@1.0 (stage, live)
685+ // carve-out absent -> p4 is the control, as on the open dev edge
686+ // Getting this wrong in the permissive direction puts a funded wallet behind an open
687+ // endpoint, which is why it is asserted behaviourally against the real edge, not from config.
688+ if ( bundlerRoutes . length > 0 ) {
689+ const edge = await get ( host , '/~bundler@1.0/tx' )
690+ check ( edge . status === 403 || edge . status === 404 ,
691+ 'bundler carve-out is paired with an edge that REFUSES ~bundler@1.0' ,
692+ `carve-out ${ bundlerRoutes . join ( ' ' ) } + edge HTTP ${ edge . status } ` )
693+ } else {
694+ check ( true , 'no p4 carve-out for ~bundler@1.0 — p4 is the control on this node' , 'none' )
695+ }
650696 }
651697}
652698
0 commit comments