@@ -27,6 +27,7 @@ import { describe, it, expect } from 'vitest';
2727import { ethers } from 'ethers' ;
2828import {
2929 EVMChainAdapter ,
30+ InsufficientPublisherFundsError ,
3031 PublisherNotAuthorizedForContextGraphError ,
3132 isPublisherNotAuthorizedForCgError ,
3233 formatPublisherNotAuthorizedForCgMessage ,
@@ -39,6 +40,7 @@ import { PUBLISHER_NOT_AUTHORIZED_FOR_CG_CODE } from '@origintrail-official/dkg-
3940
4041const CG = 7n ;
4142const AUTHOR = '0x1111111111111111111111111111111111111111' ;
43+ const lc = ( s : string ) => s . toLowerCase ( ) ;
4244const PK_A = `0x${ '1' . repeat ( 64 ) } ` ;
4345const PK_B = `0x${ '2' . repeat ( 64 ) } ` ;
4446
@@ -162,6 +164,48 @@ describe('#1689 publish admission — diagnostic accuracy [CH-1689-D]', () => {
162164 ) ;
163165 expect ( error . message ) . toContain ( `deployed version is ${ BELOW_THRESHOLD } ` ) ;
164166 } ) ;
167+
168+ // The facts are computed ONCE at throw time. If they live only in the rendered
169+ // message, the next consumer question — "not rotated yet, or author refused?" —
170+ // forces message-parsing, which is the exact coupling this PR removes. So the
171+ // thrown object must expose what the formatter was given, and the two must agree.
172+ it ( 'the thrown error carries the structured facts its message was rendered from' , async ( ) => {
173+ const { a } = makeAdapter ( [ AUTHOR . toLowerCase ( ) ] , BELOW_THRESHOLD ) ;
174+ const payer = a . signerPool [ 0 ] . address ;
175+
176+ const error = await caught ( ( ) => a . resolvePinnedPublisherSigner ( CG , payer , AUTHOR ) ) ;
177+
178+ // The two fields that answer the question without touching `message`.
179+ expect ( error . details . attestedAuthorConsidered ) . toBe ( false ) ;
180+ expect ( error . details . deployedLifecycleVersion ) . toBe ( BELOW_THRESHOLD ) ;
181+ expect ( error . details . minLifecycleVersion ) . toBe ( ATTESTED_AUTHOR_PUBLISH_AUTHZ_MIN_KAL_VERSION ) ;
182+ expect ( error . details . contextGraphId ) . toBe ( CG ) ;
183+ expect ( lc ( error . details . payerAddress ) ) . toBe ( lc ( payer ) ) ;
184+ expect ( error . details . attestedAuthorAddress ) . toBe ( AUTHOR ) ;
185+
186+ // Facts and text must describe the same rejection — a message rendered from
187+ // different values than the object reports would be worse than either alone.
188+ expect ( error . message ) . toBe ( formatPublisherNotAuthorizedForCgMessage ( error . details ) ) ;
189+
190+ // Frozen: one error's diagnosis cannot be mutated into disagreeing with its text.
191+ expect ( Object . isFrozen ( error . details ) ) . toBe ( true ) ;
192+ } ) ;
193+
194+ it ( 'carries the pool facts on a signer-pool rejection, author consulted and refused' , async ( ) => {
195+ const { a, pool } = makeAdapter ( [ ] , ATTESTED_AUTHOR_PUBLISH_AUTHZ_MIN_KAL_VERSION , {
196+ extraKeys : [ PK_B ] ,
197+ } ) ;
198+
199+ const error = await caught ( ( ) => a . _authorizedPublisherSigners ( pool , CG , AUTHOR ) ) ;
200+
201+ // Author WAS weighed here (supported lifecycle) and refused — the opposite
202+ // diagnosis to the version-gated case above, distinguishable without parsing.
203+ expect ( error . details . attestedAuthorConsidered ) . toBe ( true ) ;
204+ expect ( error . details . deployedLifecycleVersion )
205+ . toBe ( ATTESTED_AUTHOR_PUBLISH_AUTHZ_MIN_KAL_VERSION ) ;
206+ expect ( error . details . payerPoolAddresses ) . toEqual ( pool . map ( ( w ) => w . address ) ) ;
207+ expect ( error . message ) . toBe ( formatPublisherNotAuthorizedForCgMessage ( error . details ) ) ;
208+ } ) ;
165209} ) ;
166210
167211describe ( '#1689 publish admission — fails closed on EVERY path [CH-1689-F]' , ( ) => {
@@ -251,6 +295,66 @@ describe('#1689 publish admission — one condition, one error contract [CH-1689
251295 } ) ;
252296} ) ;
253297
298+ describe ( '#1689 publish admission — author-aware NO_FUNDED enrichment [CH-1689-E]' , ( ) => {
299+ // `enrichInsufficientPublisherFundsError` decides whether a funding failure is the
300+ // TERMINAL `NO_FUNDED_PUBLISHER_WALLET` ("no wallet is a viable reroute") or a
301+ // recoverable wrong-pick. It answers that with `poolHasFundableSigner`, which must
302+ // be asked on the SAME two-principal rule the publish is held to: an attested
303+ // author the CG authorizes makes every funded wallet admissible.
304+ //
305+ // Nothing else guards the author argument at that call site. Drop it and the
306+ // payer-only NO_FUNDED tests still pass, while a recoverable reroute is
307+ // misreported as terminal — a misclassification in the same family as the bug
308+ // this PR fixes. Mutation-proven: removing the argument fails exactly this test.
309+ it ( 'an authorized author keeps a fundable pool wallet a viable reroute (NOT terminal NO_FUNDED)' , async ( ) => {
310+ const { a, pool } = makeAdapter (
311+ // ONLY the attested author is authorized — no wallet in the pool is.
312+ [ AUTHOR . toLowerCase ( ) ] ,
313+ ATTESTED_AUTHOR_PUBLISH_AUTHZ_MIN_KAL_VERSION ,
314+ { extraKeys : [ PK_B ] } ,
315+ ) ;
316+ const [ pinned , other ] = pool ;
317+ // The pinned payer is short on TRAC; the other pool wallet can cover the cost.
318+ a . getWalletFunding = async ( address : string ) => (
319+ address . toLowerCase ( ) === pinned . address . toLowerCase ( )
320+ ? { native : 10n ** 18n , trac : 0n }
321+ : { native : 10n ** 18n , trac : 10n ** 18n }
322+ ) ;
323+ a . isWalletPublishFundable = async ( address : string ) =>
324+ address . toLowerCase ( ) !== pinned . address . toLowerCase ( ) ;
325+ a . snapshotPublisherWalletBalances = async ( ) => [ ] ;
326+
327+ // A TRAC shortfall surfaces as a funds-shaped transferFrom revert.
328+ const original = new Error ( 'ERC20: transfer amount exceeds balance' ) ;
329+ const enriched = await a . enrichInsufficientPublisherFundsError (
330+ original , pinned , CG , 1_000n , AUTHOR ,
331+ ) ;
332+
333+ expect ( enriched ) . not . toBeInstanceOf ( InsufficientPublisherFundsError ) ;
334+ // The original error is preserved so a retry can reroute to the funded wallet.
335+ expect ( enriched ) . toBe ( original ) ;
336+ void other ;
337+ } ) ;
338+
339+ it ( 'with no author, an unauthorized-and-unfunded pool is still terminal NO_FUNDED (unchanged)' , async ( ) => {
340+ // The payer-only behaviour this must not disturb: nothing admissible is
341+ // fundable, so the whole-pool diagnosis is correct and stays terminal.
342+ const { a, pool } = makeAdapter ( [ ] , ATTESTED_AUTHOR_PUBLISH_AUTHZ_MIN_KAL_VERSION , {
343+ extraKeys : [ PK_B ] ,
344+ } ) ;
345+ const [ pinned ] = pool ;
346+ a . getWalletFunding = async ( ) => ( { native : 10n ** 18n , trac : 0n } ) ;
347+ a . isWalletPublishFundable = async ( ) => false ;
348+ a . snapshotPublisherWalletBalances = async ( ) => [ ] ;
349+
350+ const enriched = await a . enrichInsufficientPublisherFundsError (
351+ new Error ( 'ERC20: transfer amount exceeds balance' ) , pinned , CG , 1_000n ,
352+ ) ;
353+
354+ expect ( enriched ) . toBeInstanceOf ( InsufficientPublisherFundsError ) ;
355+ } ) ;
356+ } ) ;
357+
254358describe ( '#1689 publish admission — cross-package version coupling [CH-1689-V]' , ( ) => {
255359 // The client threshold and `KnowledgeAssetsLifecycle._VERSION` are two literals in
256360 // two packages maintained by different people. A mismatch fails NOTHING: the
0 commit comments