Pearl-1 deployment record — 2026-09-20
Deployed from commit 6fd4e22 (source byte-identical to repo files). Signer/deployer for every tx: agent key g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3. Vault realm address: g1vfp5q3fkuv5pnefzvju6dlu7atulg59pl6264l.
| # | Action | Height | Tx hash |
|---|---|---|---|
| 0 | faucet grant 10 GNOT (faucet-signed) | — | 5518cd2c795695cbcead90aac6e69ade20ad847f85a6e9b9515e5e27bd6aef1d |
| 1 | addpkg p/.../feeledger | 572441 | feb4b559aecd184494f4c0786732efce263626811edd4f4bacfb2b9563c1f2a0 |
| 2 | addpkg r/.../vault | 572482 | 829993dd958f49c13028db79cc12b907d452302d025a1c629357a00ffa5c8fb8 |
| 3 | Deposit 1,000,000ugnot @0bps | 572490 | 404abe77462a961382bee39a0d272769f2a945b7199dfbb095c9ca44e4446555 |
| 4 | SetFeeBps(250) | 572498 | 33e67a658d1146633699dada11ade62e07243e2fad897c905aa875e47ce3a2b6 |
| 5 | Deposit 1,000,000ugnot @250bps (fee 25,000) | 572501 | f7b8c7e1eb6b5d8a1af7675f76d19c46e03c900b2e8109e550cf20c13b28ec25 |
| 6 | Claim(400,000) | 572509 | 81735ae431ed86b5885259913eea932a7675bb5455fbf0138840a3f8a78448ba |
| 7 | WithdrawFees (25,000) | 572525 | be6a6f39011b11f8fa398fe2e8a5a17cb15a7cf56b33590709e1d12341427b38 |
| 8 | addpkg r/.../cointest (helper) | 572543 | b19f57d705b450f1a022b1b6bb760b9015cb4cc4a72c491857d9ab5c53f7ccf6 |
| 9 | ForwardUgnot 500,000 → vault (out-of-band) | 572549 | a5921076f230f79914ae9b72930865f7fde1104dd573e90818ebb32ff4ec0bef |
| — | cointest.MintAndSend REJECTED at simulation: IssueCoin requires prefixed denom | — | (not broadcast) |
| 10 | addpkg r/.../cointest2 (fixed denom) | 572561 | 7ce72ecd16ffb822d7e1a42aed7aaad573475d028321d309eb81b8a339be2eeb |
| 11 | cointest2.MintAndSend 12,345 junk → vault | 572567 | 4bac7cec84778d70de13f1a4eb8f66be79384122e918e5a83c3ef5d308526382 |
| — | cointest.TryDeposit (realm→vault.Deposit) REJECTED: "deposit must be a direct EOA call…" | — | (not broadcast) |
| 12 | SweepDenom(/gno.land/r/…/cointest2:junk) | 572579 | 3fc9991124432bdaf4d1d396ff9c0564d3b592a7d1fb61fb35c5bf607e366a6c |
| 13 | SweepDenom(ugnot) 500,000 surplus | 572583 | 5575a4d018e016d07ecd73fab360ec165c487c78223e6807ec7206029e1de218 |
| 14 | ClaimAll (1,175,000) | 572586 | a5cbe61714817129af422d30ea12ccc775ecca6ec551824dbe2732c056811219 |
Conservation H == U + F + S verified against auth/accounts after steps 3, 5, 6, 9, and 14 — exact equality at every checkpoint; final state all zeros. Live-chain discrepancies vs local harness:
- IssueCoin needs the fully-qualified "/pkgpath:sub" denom on-chain (harness accepted bare subdenoms).
- auth/accounts
coinsomits realm-issued denoms (bank state for them is only visible via banker GetCoin/GetCoins). - init() captures OriginCaller/CurrentRealm correctly on-chain (harness runs init with empty context — known limitation).
Pearl-1 composability deployment — 2026-09-20 (bounties)
Deployed from commit f950783. r/.../bounties imports the ALREADY-DEPLOYED p/.../feeledger (no dependency redeploy — second live consumer). Realm address: g1x8fmlk48ucjzwslma92a2eetjqk0lp7afkrjqm. Second party: agent key "hunter" = g19tv9thaymj70zy0p285uh47rneqt90wep9lqt5.
| # | Action | Height | Tx hash |
|---|---|---|---|
| 1 | addpkg r/.../bounties | 573203 | 1e67d09e07266d0a50bda463686fd8338d7b56adad333fdf6869d10685294f86 |
| 2 | key_send 100,000 → hunter | 573210 | 9fc3b7b6b70480cdc370b401530e9eb11f36637c19bbdc0db8c3ca041da859e6 |
| 3 | SetFeeBps(250) | 573215 | 01978eed5f068724327970e15ff96f2b448953c1afe724318bb0541ec0ffb3c1 |
| 4 | CreateBounty #1, -send 1,000,000 (snapshot 250) | 573220 | 9138ea56d3ab32e3fd0be0a1f49bcb9cc50e68e00b64b66aa04dde18b6c95864 |
| 5 | SetFeeBps(0) — attempted "front-run" | 573224 | 178c80d43780f0ecd2ad50edb147ca09c8759af540f7995b263c3a6fd3ce115b |
| 6 | Award(1, hunter) — fee 25,000 CHARGED PER SNAPSHOT | 573228 | 62f00f4151f8f1ab908609a1a9e42c18c58f0197eaf1cad4fd09105b4e8d4f15 |
| 7 | hunter ClaimAll 975,000 (signed by hunter) | 573235 | 61bb22eca525b985068a957196b01dab293208a87b08b060bda13f1e35ae2c3e |
| 8 | hunter → default 900,000 (working capital) | 573273 | dc5e9c039dc3d4320c0b618cdeef50d6602877d18ef1b709e718d1686e5e2cce |
| 9 | CreateBounty #2, -send 100,000 | 573277 | 9505b7fc920562c1803e7b3f2cc56210bb203accd6218455abec6867b738c07a |
| 10 | Cancel(2) — fee-free refund | 573280 | cb3b5817026a31aa7a4b261172703cbbfd3ca216bac84bd56bcc98b453173902 |
| 11 | funder ClaimAll 100,000 | 573287 | 8c1ac25b8eec00a3e4ba3ea462b39f7035252cc5006847c768f5a043c91012cb |
| 12 | WithdrawFees 25,000 | 573291 | 41d65f5d3f556fb9ec352555be52409501787fc9fbb0126670c00b9c88cf1319 |
Composed invariant H == B + U + F + S verified against auth/accounts after steps 4, 6, 7, 10, and 12 — exact at every checkpoint; final state all zeros with both bounty records terminal in Render.
New live-chain findings:
- Per-call STORAGE DEPOSITS are locked from the TX SENDER for state a call creates (bounty records, ledger entries) — one CreateBounty failed simulate with lockStorageDeposit/insufficient-coins until the sender was topped up. Not modeled by the local harness.
- Storage refunds credit the DELETER: hunter's ClaimAll removed its ledger entry and collected the refund (explains its +82,800 balance anomaly). Settles the audit's open question: dust-record spam is self-funded by the spammer and sweeping state pays the sweeper.
bounties vNext vs deployed v1 — behavioral deltas (audit Y2 record)
The local tree (1c256fe+) differs from deployed bounties v1 in THREE deliberate behavioral changes, all from the 2026-09-20 full-sweep audit round (commit 2e20f18), NOT from the coinio refactor:
- Award rejects winner == realm address (dead-liability guard G9).
- BountyCancelled event gained an "amount" attribute (G7) — EVENT SCHEMA CHANGE: indexers keyed to the v1 shape must handle both.
- Render fee line reworded to snapshot semantics (G8).
The coinio refactor itself (1c256fe) changed only panic-message prefixes ("coinio: ...") plus internally-unreachable extra guards. Vault vNext differs from deployed v1 in message text only.
Pearl-1 primitive-extraction deployment — 2026-09-20 (coinio + coindemo)
Deployed from commit ec5f78d (custody byte-verified for both files).
| # | Action | Height | Tx hash |
|---|---|---|---|
| 0 | faucet grant 10 GNOT to hunter key (faucet-signed) | — | 31ea506d5af4a3a270274205367456f3de3ee39d593a285cecded5d538a76b45 |
| 1 | hunter → default 195,000 | 583566 | 4cc3aa9192ffc51ad9b5166ed4bccdf300f36fcc24c678280390f2b673ad657f |
| 2 | addpkg p/.../coinio | 583585 | 31b53d1b763132576eb85fce25fb7d3881b279351d0949e18a72c366167706ae |
| 3 | hunter → default 9,000,000 | 583605 | 5641e6f4aa95781f49fbd3880609c6e4978e4d89396309aa37456a2f28acd725 |
| 4 | addpkg r/.../coindemo (resolves coinio + feeledger on-chain) | 583613 | 38aa65019a1a5dee3b3e8509498f7d9c52cdfa9b5fcca59bb4a05b5957c6908c |
| 5 | Deposit 300,000 (via coinio.Receive) | 583622 | a43fe6b99f666a1599aae7597ea5589a78a0dc0c483413c374c809ab03a48e92 |
| 6 | cointest.ForwardUgnot 150,000 → coindemo (out-of-band) | 583630 | 85a59193bba3e3e0a3703c5d1992d168c958a5080ba796a34f6fca16b2c36ea9 |
| — | hunter Sweep attempt REJECTED at simulation: "owner only" | — | (not broadcast) |
| 7 | owner Sweep — exactly 150,000, liabilities untouched (coinio.Sweep) | 583636 | fe761c91c99411bef3cecc7eb7a43d785b4232b19849eb5ec4e3b40e5172f846 |
| 8 | ClaimAll 300,000 (via coinio.Payout) | 583643 | 7ab56a43f24f18b71bdca6086afb632a7c7dd927f9d347f3efb65495bd97f606 |
| 9 | TransferOwner → hunter | 583647 | dda4b15e3dced23b0279fdd376e4cf8bc2ea14b7721094a0a2118308d8eb6f4a |
| 10 | hunter TransferOwner → agent (signed by hunter) | 583651 | 7b2b6e03fdc07618f774956cdaa3354fffda059ea7e12fc20578b71b2f8f52be |
Conservation H == U + S verified against auth/accounts after steps 5, 6/7, and 10 — exact; final state all zeros; Render "conservation: OK". Both deployed files byte-verified via vm/qfile (local coinio.gno had one trailing blank line normalized to match).
Pearl-1 grants-market deployment — 2026-09-20 (benchmark)
Deployed from commit 0e30a95 (byte-verified via vm/qfile immediately after broadcast). Realm address: g10wjaak8z5nhzke76qxmsjdu2rjxcnm5j6zw54m. Two parties: agent "default" (creator/admin/recipient), "hunter" (applicant/winner/expirer) = g19tv9thaymj70zy0p285uh47rneqt90wep9lqt5.
| # | Action | Height | Tx hash |
|---|---|---|---|
| 1 | addpkg r/.../grants (resolves coinio+feeledger+avl+sanitize) | 584731 | 6093675f15c27702f8c8599309e1a40e5964b96973369547ee11ba301f8a3f60 |
| 2 | SetFeeBps(250) | 584743 | 083d3b63ba1e79f1d7d05a45cfb29fbb64021f539487679e7c97fc183031ab17 |
| — | CreateGrant with ceiling 100 REJECTED: "current fee 250 bps exceeds the caller's maximum 100" (live audit-Y1 regression) | — | (not broadcast) |
| 3 | CreateGrant #1, -send 1,000,000, ceiling 250 (snapshot 250) | 584754 | ac2aa397cb776ecd24e7b20d2b42666d4c10da63ad9cc264d726fdf2145ef92b |
| 4 | hunter Apply(1) | 584758 | ea1823018d9e86295b34d3d85786f8de81999bc2cb1a2860880d323cf1821e0d |
| — | hunter SelectWinner REJECTED: "only the grant creator may select a winner" | — | (not broadcast) |
| — | creator SelectWinner(non-applicant) REJECTED: "winner must be an applicant of this grant" | — | (not broadcast) |
| 5 | SetFeeBps(0) — attempted award-time "waiver" | 584769 | 4ba7422c4497a2f68cbce083a27aec87655f3c47d6d57e240bc865d5a53c2e9b |
| 6 | SelectWinner(1, hunter) — fee 25,000 CHARGED PER SNAPSHOT | 584773 | 4d1a0a27f884a9af5435c56cd9e589cdf1d091706eefb7056547358afdaa6eb6 |
| 7 | CreateGrant #2, -send 100,000, duration 2 blocks | 584782 | fb3d2624b94e0354cfd292c56f0dff3384aff9b128431fb3f44709650545eafd |
| 8 | hunter ClaimAll 975,000 (own signature) | 584786 | 5210234b196e66ca10499878c0296a0d51877473fb39110f6864cc7cbf25588d |
| 9 | WithdrawFees 25,000 | 584790 | 749c8335fcbf7685fd74425a39c9b8740a9120cb7f97287bd29edb9a1adf8008 |
| — | hunter ExpireGrant(2) premature REJECTED: "grant is not expirable yet" | — | (not broadcast) |
| 10 | hunter ExpireGrant(2) at h>=deadline+100 — refund 100,000 to the CREATOR (expirer got nothing) | 584892 | f2038505b0b5f1c2dd76aecd075d827e3680289dc8349f3c7ad706ca6df6f9b8 |
| 11 | creator ClaimAll 100,000 | 584899 | 5b24268782fcf7ef77ed19b9991e7900630364f00117e0532b32f57bad7fd0bd |
Conservation H == G + U + F + S verified against auth/accounts after steps 3, 6 (bank exactly 1,000,000 == U 975,000 + F 25,000), 10, and 11 — exact at every checkpoint; final state all zeros; live Render shows both terminal records and "conservation: OK"; awarded-detail render (winner line, sanitized description) probed live.
Pearl-1 marketplace deployment — 2026-09-20/21 (multi-domain benchmark 1)
Deployed from commit 8088d1e (byte-verified via vm/qfile). Realm address: g1y8nzwrv7ufe8c5r747vfpjm8jvayhgvddqwl2g. Parties: agent "default" (seller/admin/recipient), "hunter" (buyer/staged admin), "keeper" (funding + wrong-acceptor attacker).
| # | Action | Height | Tx hash |
|---|---|---|---|
| 0 | faucet grant 10 GNOT to keeper (faucet-signed) | — | 725bfa7f93a21a68f452c5f0766ba18b4cc41dda20306e5b3911e1418db9a15e |
| 1 | keeper → default 9,500,000 | 585757 | 5c7230960b3307458bfa02fe46b210dd631c1fa6e1c565244467b436fbeb4e4d |
| 2 | addpkg r/.../market (resolves all 4 on-chain deps) | 585791 | 76293576775be9db30ab097944990e9c5836d00e52a84b979d3284b7707f82d9 |
| 3 | SetFeeBps(250) | 585801 | 070a0442e681d09ce42b2cafa63004f02aa44311574b33537d118c461c7698ac |
| — | CreateListing ceiling 100 REJECTED: "current fee 250 bps exceeds the seller's maximum 100" | — | (not broadcast) |
| 4 | CreateListing #1, 500,000, ceiling 250 (snapshot 250) | 585808 | b3410fb57a8f987c527d521b46bbbc541c4c6258420ec5403dd31783b628f9e6 |
| 5 | SetFeeBps(0) — attempted fee "waiver" | 588622 | 12b770381ddf59fc272e2502158e6b997a22a0b22e92d3e7527f1a970f7a6d8f |
| — | hunter Buy 400,000 REJECTED: "send exactly the listed price: 500000ugnot" | — | (not broadcast) |
| 6 | hunter Buy #1, -send 500,000 — fee 12,500 CHARGED PER SNAPSHOT | 588630 | 3ad1ad509564c0cc75d2efbfeb712a268b17a4d82e1185a0193541b620fe0817 |
| — | hunter re-Buy #1 REJECTED: "listing is not active" (no double purchase) | — | (not broadcast) |
| 7 | CreateListing #2, 300,000 | 588647 | 9535418ef12a75ecbe504348ec4a5b845f358a794596aac325bca2f878672a95 |
| — | hunter CancelListing REJECTED: "only the seller may cancel" | — | (not broadcast) |
| 8 | seller CancelListing(2) | 588654 | b82d3c558a2d27f609fc7a2b1f092438e95e85ffed4dd99fc2b6addc5cd1a406 |
| — | hunter Buy cancelled #2 REJECTED: "listing is not active" (buyer coins fail-closed) | — | (not broadcast) |
| 9 | seller ClaimAll 487,500 | 588662 | 4be5195289ba60860328c0762ec56bac07e48c4990afbabff39e7f077882a903 |
| 10 | WithdrawFees 12,500 | 588666 | f5b4760067a2040d67ad1e82b76861785d2e23e72fe14fd4813cb0abdccd3ecf |
| 11 | TransferAdmin stage → hunter | 588670 | 66c4fc823e49800692079f69482c53803086cdc279fe970c6c9c316a6f0cc106 |
| — | keeper AcceptAdmin REJECTED: "caller is not the staged admin" | — | (not broadcast) |
| 12 | hunter AcceptAdmin (2-step complete) | 588678 | c39b43002f259d4e5f9f295115e5d316b1111aaa0d9126c13bb077848d5d2678 |
| 13 | hunter TransferAdmin stage → agent | 588682 | 9fbfc0afa638c3c7a16b41995994614a0f1ab487144a4a398fd3eb440a119702 |
| 14 | agent AcceptAdmin (restored) | 588686 | 5c2d47f4b37030c4b62d776bf932eb005111273f5359ba7e3343c560c2bd016a |
| 15 | cointest.ForwardUgnot 150,000 → market (out-of-band) | 588691 | ab8e615e8dc7cdf799a9de14a12f5d9528f8bcf41d717c883b9a49517bda36f4 |
| 16 | SweepDenom(ugnot) 150,000 | 588695 | c5ee7311d0621e91de808f6d7ccc1957a79dd780169b7fb6842a0539c132b671 |
Conservation H == U + F + S verified against auth/accounts after the sale (bank exactly 500,000 == U 487,500 + F 12,500) and at the final drain (bank "0", all views zero) — exact; live Render shows both terminal listings and "conservation: OK". Seven distinct attack shapes rejected live at simulation (no gas wasted, no state touched).
Pearl-1 GitHub-pipeline deployment — 2026-09-21 (fee_split, §7)
Source of truth: github.com/SillyZir/fee_split @ main = 819239ad5ddc46cf685f5ff363cabb7290848c1f (clean clone, sha256 of both realm files matched into staging). Deployed source = port.py(committed bytes): T1 namespace rewrite (gno.land/r/fee_split -> gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/fee_split) + T2 mechanical API-era port (chain/runtime.{OriginCaller,PreviousRealm, CurrentRealm} -> chain/runtime/unsafe; banker.OriginSend -> unsafe.OriginSend; NewBanker(bt) -> NewBanker(bt, cur) with _ realm -> cur realm in Claim/ClaimFees only; tests: bare cross -> cross(cur), 3-arg -> 4-arg uassert.Aborts*, NewReadonlyBanker()). Every substitution count-pinned in pearl/r/feesplit/port.py; workspace commit 3e30197. 55/55 tests, lint clean; gno-auditor fresh-context: 0 RED / 3 YELLOW (Y1 upstream stack-walker fragility report-only, Y2 test gaps upstream + GetFeeInfo post-deploy check DONE, Y3 preconds verified). Discovery gate: 626 pkgs enumerated; splitpayment (string-identity, no custody) and moul splitter/v1 (accounting-only) RELATED/unsuitable; no exact duplicate.
- addpkg fee_split: tx 061c89b1fd34f696396a2ee4e0b39dc7a808cdb0e5916763d39a7afe80c43c16 h596803 SOURCE MATCH PASS: vm/qfile fee_split.gno byte-identical to commit 3e30197 AND to port.py(GitHub@819239a) re-run; gnomod.toml delta = chain-appended [addpkg] creator/height block only. Realm addr g12v97h3jexeacya6r4pkrm722axgahet25e6lms (derivation cross-validated against market's known pair). init admin capture verified: GetFeeInfo admin == deployer g1ut6...agvm3, fee 0.
- CreateSplit(hunter,keeper 7000,3000) -> split_1: tx 02259b37 h596821
- Deposit 100000 (fee 0): tx 5d815c5e h596834; bank 100000 == 70000+30000+0 EXACT
- Claim hunter 70000: tx ec40b9f3 h596844; hunter delta exactly +70000-20000gas
- SetFee 100bps: tx 05d4788f h596848
- Deposit 50000 (fee 500, net 49500 70/30): tx b4874d1c h596851; bank 80000 == 34650+44850+500 EXACT
- 6 live attack rejections at simulate (no state touched): keeper SetFee, non-recipient Claim, stray-send Claim(+1000), SetFee 101 (cap), hunter UpdateShares, coinless Deposit — exact panic messages.
- Drain: ClaimFees 500 (tx fd5a624f h596866), Claim hunter 34650 (a7ca6240 h596868), Claim keeper 44850 (7e92b351 h596870); bank == "0" EXACT (conservation checkpoint 3).
- Lifecycle: Freeze (7e1e149e h596874) -> Deposit rejected "split is frozen"; Archive (19646fca h596880) -> Deposit rejected "split is archived: split_1" (closes audit Y2 gap 3 with live evidence); Render "No active splits."
Per-split conservation sum(balances)+TotalClaimed==TotalDeposited held at every checkpoint (149500 == 0+149500 final); realm-level bank == Σ claimables + feesAccrued verified against auth/accounts at three checkpoints, all exact. Verdict: PASS.
Pearl-1 GitHub-pipeline deployment — 2026-09-21 (timelock_guardian, §8)
Source of truth: github.com/SillyZir/timelock_guardian @ main = bb05e64898967125b900a9275ca84229bff5e007 (the user-authorized remediation commit, authored solely by SillyZir). Transformation: T1 namespace-only (gnomod module path); the deployed timelock_guardian.gno is BYTE-IDENTICAL to the GitHub commit. Pre-deploy: 50/50 tests, lint clean, three audit rounds (final: 0 RED / 0 YELLOW). Preflight re-verified workspace == fresh clone bytes; agent topped up via faucet (tx 8bf5450b).
- addpkg timelock_guardian: tx 6db3f74c764523f8ea1d82f7cd419639a8bb597494b170a8d2ea56bc8779efbb h602213 SOURCE MATCH PASS: vm/qfile bytes == workspace == fresh GitHub clone @ bb05e64; gnomod delta = chain [addpkg] creator/height block only. Realm bank addr g1w06mqv4h53vtfgfeyz2eglplpwfpytcqqqjwkh. No deployer privilege exists (init allocates maps only — verified).
- RegisterTarget cosmic_upgrades (owner=default, guardian=keeper, min 60s): tx 8334af8e h602224
- Schedule action_1 (60s): tx c06841cb h602232; Execute-too-early rejected "too early: 50s remaining" (integer seconds — fix Y-3 visible live); IsReady false; GetAction windows exact (+60s, +30d)
- Execute action_1 by HUNTER (anyone-executes): tx 0eb72c7c h602254; IsExecuted true; double-execute rejected
- Schedule action_2: tx 416d309a h602260; VETO by keeper (guardian): tx 97359a0c h602270; reap proven (Execute -> "action not found")
- Schedule action_3: tx 5e81ec16 h602274; Cancel by owner: tx 06d1a05a h602277
- HTML-injection probe (audit Y3 escalation check): action_4 with <script>/<img onerror>/[link] payload (tx 5fd8c53b h602279) -> gno_render AND raw gnoweb HTML show fully neutralized text "(script)alert(1)(/script)..."; ZERO raw hostile tags in the served page. Y3 settled SAFE. Cancelled (tx a4d293f4 h602287).
- Two-step ownership: offer default->keeper tx da35a2a8 h602289 (GetTarget shows Pending owner); WRONG-ACCEPTOR hunter rejected; Accept by keeper tx 60199148 h602296; ORIGINAL CREATOR rejected from Schedule post-transfer; keeper Schedule action_5 tx 25e1683a h602302, Cancel tx 493db9e0 h602306
- Expire guard matrix live: live action -> "action is not expired"; executed -> "action already executed"; ghost -> "action not found". HONEST LIMIT: the Expire/expired-reaping SUCCESS path needs 30 real days past readiness (GracePeriod) and is not reachable live in a session; it is pinned by TestExpire_PermissionlessRecovery and TestVictimRecoversBehindSybilWall in the deployed-identical suite.
- 8-probe adversarial battery, all rejected at simulate: foreign Schedule, foreign Cancel, non-guardian Veto, duplicate register, sub-floor minDelay, below-target-min delay, guardian-strip while pending, coin-attached Schedule ("this realm does not accept coins" — fix Y6 live).
Bank invariant: realm address exists:false / coins "0" at every checkpoint (post-deploy, mid-flow, final) — the realm never received a single coin of any denomination. Every abort message byte-matched the audited source. Verdict: PASS.
Pearl-1 GitHub-pipeline deployment — 2026-09-21 (upgrade_registry, §9)
First run under the application-level authorization model (one auth = full lifecycle). Source of truth: github.com/SillyZir/upgrade_registry @ main = 2d5a74870b08d70ff8e56e6e1efbace1803dd3bd (remediation commit, authored solely by SillyZir, 0 AI attribution, verified via fresh clone + GitHub API; base commit 9d22f7f). Pipeline: port 8e54fcb (mechanical, 19/19) → audit (0 RED / 5 YELLOW) → remediation 81ab793 (all 5 fixed: cur-realm identity, stray-send guard, GetLatest cost doc, freeze-wedge doc, 8 gap tests → 27/27; verification audit 0R/0Y) → upstream push → fresh-clone verification (27/27 from GitHub bytes) → deploy. Transformation: T1 gnomod-only; the deployed .gno is BYTE-IDENTICAL to the GitHub commit.
- addpkg upgrade_registry: tx 532be90ab90521b0d6d4b3f983ede9dd315c8975cf30ad5c8984837f4d57e34d h602876 SOURCE MATCH PASS (vm/qfile == workspace == fresh GitHub clone). Realm bank addr g1g453tcmapqn6hj9zlydfz3mt3vxh8gq5vwv8sg. No deployer privilege (init allocates maps only).
- Register demo_vault_v1 by hunter (nominates default): tx 4bd3666a h602886
- Register demo_vault_v2 by keeper (nominates default): tx 7c5e22cd h602888
- AcceptOwnership v1-entry by default: tx 1b6b96db h602896
- AcceptOwnership v2-entry by default: tx 4e902d51 h602906
- Deprecate v1 -> v2 by default: tx f3d9cd8b h602917; GetLatest(v1)==v2, GetMigrationChain correct with [deprecated] tag, GetInfo correct, GetOwnerContracts(default) == both entries; Render: v2 Active / v1 Deprecated with successor.
- 8 live adversarial rejections at simulate (zero state touched): pre-acceptance Deprecate ("only the owner"), successor-consent forgery ("successor entry is not owned by the caller" — the provenance-forgery guard), wrong-acceptor AcceptOwnership, self successor, unregistered successor, duplicate Register, invalid name, coin-attached Register ("this realm does not accept coins" — audit Y2 fix live).
Bank invariant: realm address exists:false / coins "0" at every checkpoint — the realm never received a coin of any denomination. Every abort message byte-matched the audited source. Verdict: PASS.
Pearl-1 GitHub-pipeline deployment — 2026-09-21 (permission_registry, §10)
Application #4, second run under the application-level authorization model. Source of truth: github.com/SillyZir/permission_registry @ main = e3d6aeaf3571ec35c2af68c5fbb756480b76ed03 (remediation commit, authored solely by SillyZir, 0 AI attribution across ALL commits, verified via fresh clone + GitHub API verified:true; base commit facb9f0). Pipeline: port c984fcc (mechanical, occurrence-pinned, 17/17) → audit (1 RED / 6 YELLOW, run inline after the auditor subagent hit its usage quota) → remediation cf89b6c (all 7 fixed → 25/25 tests) → upstream push → fresh-clone verification → deploy. Transformation: T1 gnomod-only; the deployed .gno is BYTE-IDENTICAL to the GitHub commit. port.py is neutered (sys.exit) — it predates the remediation layer and rerunning it would silently revert the fixes.
Findings resolved before deployment: R1 namespace monopolization (MaxResourcesPerAdmin=20 + O(1) adminResources counter; MaxResources 200→1000); Y1 latent Class-2 caller identity (caller() helper deleted, all 8 entrypoints take cur realm); Y3 unbounded Render; Y4 one-step TransferAdmin bricking a resource (now two-step with nominee consent + cancel); Y5 no stray-send guard; Y6 untested declared bounds; Y7 undocumented integrator contract on Has. Expired-tombstone growth assessed and graded INFORMATIONAL with reasoning recorded in FIXES.md (retired is never iterated; cost falls on the caller's own storage deposit) — NOT downgraded to reach deployment.
- addpkg permission_registry: tx a30c09427173a45553a9befcac38d410aa0e416eb785145942ee5378d6a8c54f h604615 SOURCE MATCH PASS (vm/qfile permission_registry.gno == workspace bytes, sha256 603fec571a55d051353e101c64c0490a5268124abda8d88ac703b9bbf26fb763, 18070 B). gnomod.toml differs only by the chain-injected [addpkg] provenance stanza — confirmed identical in form on the already-verified siblings, so metadata, not divergence. Realm bank addr g1x9tatmqcdlas2w6k724mrs2zcgh3awf3s5wkhk (derivation method validated by reproducing all three recorded sibling addresses). No deployer privilege (init allocates maps only; no global admin).
- CreateResource cosmic_bull_demo by default: tx e8710d55 h604640; GetAdmin == default (cur.Previous().Address() correct live — Y1 fix).
- Grant writer->hunter by default: tx 1025b9d5 h604645; Has(...)==true, Has(... keeper)==false.
- TransferAdmin ->keeper by default: tx 07e053f2 h604657; GetAdmin STILL default, GetPendingAdmin == keeper — nomination is inert (Y4).
- AcceptAdmin by keeper: tx 5dfb34be h604662; GetAdmin == keeper, pending cleared, pre-existing writer grant survived the handoff.
- CreateResource stray_send_probe by default: tx 5ddb5a0c h604676 (control: identical call WITHOUT coins succeeds).
- DeleteResource stray_send_probe by default: tx 0215c70e h604679.
- CreateResource stray_send_probe reclaim by default: tx aa2c11e8 h604683 — creator reclaim right preserved after the squat attempt was refused.
- Revoke writer from hunter by keeper: tx a3dac76d h604688; GetPermissions == "none" and Render shows "No permissions defined" (the emptied permission was pruned from permList — live proof).
- 7 live adversarial rejections at simulate (zero state touched, zero sequence consumed): non-admin Grant / DeleteResource / TransferAdmin by a PERMISSION HOLDER ("permission denied: caller is not admin" — holding a permission confers no admin rights); consent forgery by a non-nominee ("caller is not the pending admin"); FORMER admin Grant after handoff ("permission denied" — authority moved, not copied, and the DEPLOYER holds no residual privilege); coin-attached CreateResource ("this realm does not accept coins" — Y5 fix live, on an otherwise-authorized call so the coin guard was the only possible cause); markdown-injection resource name "evil|`](http://x)" rejected by the charset rule (Y3/injection defense).
Not exercised live, correlated to committed tests (economically impractical on-chain — each would need hundreds of funded txs): MaxResources=1000 global cap (TestCreate_GlobalCap, fills across an address fleet), MaxResourcesPerAdmin=20 (TestCreate_PerAdminCap), MaxPermissionsPerResource=50 (TestGrant_PermissionCap), MaxHoldersPerPermission=200 (TestGrant_HolderCap), Render truncation notices (TestRender_Bounded), 90-day reservation expiry (TestReservation_ExpiresAndFreesTheName, via testing.SkipHeights).
Existing applications untouched: on-chain [addpkg] heights re-read AFTER this deployment are fee_split 596803, timelock_guardian 602213, upgrade_registry 602876 — all byte-matching their original records. No redeployment, no fee_split_v2.
Bank invariant: realm address g1x9tatmq... exists:false / coins "0" at every checkpoint, including after the stray-send attempt — the realm never received a coin of any denomination. Signer for all writes: agent key default g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 (seq 72→79, 7 txs), plus keeper g1hgy00929jus5yanz6jnyq056885z9fpqd3t68m for the two consent-side calls. Every abort message byte-matched the audited source. Verdict: PASS.
Pearl-1 GitHub-pipeline deployment — 2026-09-21 (service_registry, §11)
Application #5, third run under the application-level authorization model, and the first to carry a committed DISCOVERY.md provenance record. Source of truth: github.com/SillyZir/service_registry @ main = 3c598d2e68f31bcd9697fc702f2f78703c4988c7 (Author = Committer = SillyZir, 0 AI-attribution hits across ALL commit messages, verified via fresh clone to /tmp/sr_fresh; base commit 8f77853a77f5). Pipeline: discovery (DISCOVERY.md, 12 related implementations classified, no ecosystem-wide uniqueness claimed) → mechanical port (occurrence-pinned, 21/21 green BEFORE remediation, so every later failure is attributable to the remediation layer alone) → audit (1 RED / 7 YELLOW) → remediation (all 8 resolved → 31/31 tests) → upstream push → fresh-clone verification → deploy. Transformation: T1 gnomod-only; the deployed .gno is BYTE-IDENTICAL to the GitHub commit. port.py is neutered (sys.exit, verified exit=1) — it predates the remediation layer and rerunning it would silently revert the fixes.
Port fidelity was re-verified INDEPENDENTLY of port.py, by a fresh inline script reading the upstream bytes directly: all eight substitution counts pinned exactly (1, 1, 1, 12, 44, 10, 11, 2) and 0 OriginCaller occurrences upstream — the transformation is total and deterministic with no unmatched residue.
Findings resolved before deployment: R1 namespace monopolization (MaxServicesPerOwner=20 + O(1) zero-free ownerServices index); Y1 latent Class-2 caller identity (stack-walking caller() helper deleted, all 6 entrypoints take cur realm and read cur.Previous().Address() inline); Y2 no stray-send guard; Y3 unbounded Render (now 25 rows + truncation notice); Y4 one-step TransferOwnership bricking an entry — and, because a bricked entry can never be deregistered, permanently holing the NAME in a shared namespace (now two-step nominate/accept with cancel, quota checked at CONSENT); Y5 hand-rolled sanitizer permitting markdown link/image phishing (replaced with p/nt/markdown/sanitize/v0); Y6 pkgPath is an unverified claim with no integrator contract; Y7 silent repointing unobservable (ServiceUpdated now carries oldpkgpath AND pkgpath).
Y6 is the finding NOT taken to its maximal fix, deliberately and on the record: requiring self-proving registration à la r/demo/defi/ grc20reg would mean only realms — never their human operators — could ever register a name. That is a FUNDAMENTALLY DIFFERENT application and therefore a hard-boundary stop, not a routine in-scope fix. The proportionate remediation shipped instead: a 3-point INTEGRATOR CONTRACT carried verbatim on Resolve, in SPEC.md and in README.md (attestation-not-proof / name-is-not-authorization / target-can-change), plus the Y7 event change that makes a repoint observable.
Two bugs were found and fixed during test authoring, both recorded because they are reusable toolchain knowledge: (1) testing.SetRealm is FRAME-SCOPED — an identity set inside a helper does not survive that helper's return, which silently ran a quota test as the empty address; (2) sanitize.InlineText escapes "." as well, so a hostname never appears verbatim in rendered output.
- addpkg service_registry: tx 60fc44a47fb8c38ca117609076d13b2dc1e75c407b1d70a70830fecf1fae30ba h605546 SOURCE MATCH PASS (vm/qfile service_registry.gno == committed fresh-clone bytes, sha256 ead72c9ea14a6358a1d259177483e91a4f048f1a85050a803c3828d0531ee996, 19781 B). Deploy payload was built from the FRESH CLONE, not the working tree, then cmp-verified against the pearl tree before broadcast; the addpkg body string was additionally round-tripped through a file and cmp'd to prove the transcription was byte-exact. On-chain file list is exactly [gnomod.toml, service_registry.gno] — no test file leaked. gnomod.toml differs only by the chain-injected [addpkg] creator/height stanza. Cost: gas_used 33,712,081, gas-fee 101,138ugnot, plus a 3,053,000ugnot refundable storage-deposit escrow (observed directly at the derived storage-deposit address g1g5ja5mzp4pj4uac9cv9cupqml2fpmzrwlfh98j). Realm bank addr g1hv2mj29cyr2z94358m5fakj2g87zaf4yhrqud2 (derivation re-validated this run by reproducing the recorded permission_registry address exactly; preimage is "pkgPath:"+path per gnovm misc.go:201, NOT the bare path — the first derivation attempt was wrong and was corrected against the toolchain source rather than assumed). No deployer privilege (init allocates 5 maps only; no admin, no pause, no upgrade hook).
- Empty-state reads before any write: ServiceCount() == (0, 1000), ListServices() == "none", Render == "No services registered." — init() ran, no nil-map panic on any read path.
- RegisterService fee_split by default: tx f33ec5c1 h605562. GetService shows Owner == default — derived from cur.Previous().Address(), never a parameter (Y1 fix live). Description rendered as "pull-based" and metadata as "basis_points": the ON-CHAIN p/nt/markdown/sanitize/v0 dependency resolves and escapes live (Y5 fix live). OwnerServiceCount(default) == (1, 20) — the O(1) quota index is live.
- Render with 1 entry: TableCell escaping confirmed in the table row, and NO truncation notice (1 < 25) — matches the TestRender_ListsEntries pin exactly.
- TransferOwnership fee_split -> keeper by default: tx a5dd234f h605624. GetOwner STILL default, GetPendingOwner == keeper — nomination is inert (Y4 fix live; this is the finding that could permanently hole the namespace, so it was exercised with two real identities).
- AcceptOwnership by keeper: tx 4a2802a1 h605631. GetOwner == keeper, GetPendingOwner back to "none", and the quota index moved atomically: OwnerServiceCount(default) == (0, 20) — zero-free, the key was DELETED not left at 0 — and OwnerServiceCount(keeper) == (1, 20).
- Return handoff, to clean up the test artifact rather than leave the portfolio's own registry entry owned by a throwaway probe key: TransferOwnership -> default by keeper tx 77fa476b h605710, then AcceptOwnership by default tx e07a7972 h605712. The two-step path is therefore proven in BOTH directions. Final live state: 1 entry, fee_split, owned by default, no nomination pending.
- 9 live adversarial rejections at simulate (zero state touched, zero sequence consumed), every abort message byte-matching the audited source: coin-attached RegisterService ("this realm does not accept coins" — Y2 fix live, on an otherwise-valid call so the coin guard was the only possible cause); pkgpath traversal "gno.land/r/demo/../ ../evil" ("invalid pkgpath element: .."); uppercase name "BadName" (charset rule); duplicate name ("service already registered: fee_split"); self-nomination ("new owner is already the owner"); AcceptOwnership with no nomination open ("no pending ownership transfer for: fee_split"); consent forgery by a non-nominee, signed by hunter ("only the nominated owner can accept"); hostile repoint by a non-owner, signed by hunter ("only the owner can update this service"); and — after the handoff completed — UpdateService AND Deregister by the FORMER owner and original DEPLOYER, both refused ("only the owner can update this service" / "only the owner can deregister"). Authority moved, it did not copy, and the deployer holds no residual privilege over an entry it created. Read-path adversarial: Resolve("does_not_exist") aborts ("service not found") while TryResolve returns ("", false) — the non-panicking variant a consumer realm needs to avoid bricking itself inline.
Not exercised live, correlated to committed tests (economically impractical on-chain — each would need dozens-to-hundreds of funded txs, and the faucet grant for the signing address was already drawn for this window): MaxServices=1000 global cap (TestRegister_GlobalCap, which stages entries directly because going through the API would need 50 addresses), MaxServicesPerOwner=20 (TestRegister_PerOwnerQuota), quota-checked-at-consent (TestTransferOwnership_QuotaCheckedAtConsent), Render truncation notice (TestRender_Bounded, 26 entries across 2 owners), markdown link-injection into a Render table cell (TestRender_RejectsMarkdownLinkInjection — the live sanitizer itself IS proven above via GetService/Render escaping, but no hostile entry was planted in a shared live registry to prove the injection case), 90-day reservation expiry and lapsed-tombstone reclaim (TestReservation_* via testing.SkipHeights).
Second-identity coverage limit, stated plainly: gno_key_generate was blocked by the Claude Code auto-mode permission classifier this run, so no NEW probe key could be created. The pre-existing hunter and keeper keys were used instead, which was sufficient for every access-control case above. No workaround via gnokey, shell signing or key-file access was attempted.
Existing applications untouched: on-chain [addpkg] heights re-read AFTER this deployment are fee_split 596803, timelock_guardian 602213, upgrade_registry 602876, permission_registry 604615 — every one unchanged from its original record, and a realm's bytes are immutable after addpkg, so an unchanged height is proof of no redeploy. permission_registry / timelock_guardian / upgrade_registry sources additionally re-hashed byte-identical to the workspace. NOTE: live fee_split source does NOT match pearl/r/feesplit/fee_split.gno (on-chain e2198ebb vs local a90db685) — this is the KNOWN and EXPECTED divergence from the undeployed yellow-remediation sitting in the working tree, not a change to the live realm; no fee_split_v2 was created. Namespace now holds 14 packages, up from 13 — exactly one added.
Bank invariant: realm address g1hv2mj29cyr2z94358m5fakj2g87zaf4yhrqud2 is exists:false / coins "0" AFTER the stray-send attempt and all three real write txs — the realm never received a coin of any denomination. Structural backing: the deployed bytes import no chain/banker and contain zero coin-moving calls (the only "banker" match in the source is the comment explaining why). Signer for writes: agent key default g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 (seq 79→82), plus keeper g1hgy00929jus5yanz6jnyq056885z9fpqd3t68m for the consent-side call. Verdict: PASS.
Pearl-1 validation, deploy NOT performed — 2026-09-21 (bounty_panel)
Application-factory benchmark app (an objective was supplied, not a repository). Source commit 0cdae88, pushed to [the project repository - private] main. No transaction was broadcast. Nothing was deployed. Nothing on-chain changed. Everything below is pre-deploy validation, recorded because it was performed; the deploy gate is recorded because it stopped the pipeline.
Validation completed (all read-only):
- Fresh clone of the pushed commit 0cdae88: bounty_panel.gno, gnomod.toml and README.md cmp-identical to the working tree, and the clone's own copy builds and passes its 27 tests independently (
ok . 0.72s). - Deploy payload sha256 (from the fresh clone): bounty_panel.gno 5445bf7a8b34fc000a03be4ea391984ba443e82621fd74f22071cb81aaa2130f (32,743 B); gnomod.toml 1ded197945aeb86d63ed627c50b246c6d4f45d4b4536f370196fb853846f3b3f (88 B).
- Dependency byte-verification — the open question both auditors raised is now closed. Local
pearl/p/coinio/coinio.gno(5,685 B) andpearl/p/feeledger/feeledger.gno(8,723 B) are byte-IDENTICAL to the live pearl-1 deployments read back throughvm/qfile. The realm was therefore tested against exactly the code it would import. - Target path
gno.land/r/g1ut6usp…/bounty_paneldoes not exist on pearl-1 (the namespace enumerates 14 packages, none of them it) — no deployment target collision. - Target realm bank address derived offline as g1cxukm68ntsaewu2qpd0x5a9xvmnpjx6fecgf4h; the derivation was validated this run by reproducing the recorded grants address g10wjaak8z5nhzke76qxmsjdu2rjxcnm5j6zw54m exactly.
auth/accountson the target returns null — no pre-existing funds. - Sibling
[addpkg]heights re-read as the pre-deploy baseline, so any later redeploy would be provable: bounties 573203, coindemo 583613, cointest 572543, cointest2 572561, fee_split 596803, grants 584731, market 585791, permission_registry 604615, service_registry 605546, timelock_guardian 602213, upgrade_registry 602876, vault 572482, p/coinio 583585, p/feeledger 572441. All unchanged — and since no deploy was made, trivially so. - Chain reachable: pearl-1 at height 607579, node chain-id matching the profile.
Deploy gate — two independent blockers, both requiring the user.
- HARNESS PERMISSION.
mcp__gnomcp__gno_addpkgis blocked by Claude Code's auto-mode permission classifier, including withsimulate:true; this run additionally confirmed thatmcp__gnomcp__gno_key_generateis blocked by the same classifier ("Auto mode could not evaluate this action and is blocking it for safety"). Per the standing rule this is reported rather than routed around — gnokey, shell deployment, alternate signing and key-file access are all forbidden and using one would defeat the block's intent. - FUNDING. The deployer key g1ut6usp… holds 955,628ugnot. Scaling the service_registry deploy (19,781 B → 3,053,000ugnot refundable storage-deposit escrow plus a 101,138ugnot gas fee) gives an ESTIMATE of ≈5.05M ugnot of deposit for this 32,743 B realm, a shortfall of ≈4.1M. All three agent keys (default 955,628; hunter 868,722; keeper 566,650) have already drawn their per-address faucet grant inside the 24h window, and consolidating all three would still leave ≈2.6M short. A newly generated key would draw a fresh 10M grant — which is exactly what blocker 1 prevents. The 5.05M figure is an extrapolation, NOT a simulate result; the authoritative number comes from the
simulate=truedry run, which could not be run.
Coverage gap recorded honestly: LIVE VERIFICATION for bounty_panel is not performed, and no behavioural claim about this realm on pearl-1 is made anywhere in this repository. Its properties are evidenced by 27 local tests and two adversarial audits only.
Pearl-1 deployment — 2026-09-21 (bounty_panel, application-factory benchmark, §12)
Amendment, not a correction. The preceding section recorded that the deploy had NOT been performed and named its two blockers. That record stands as written — it was true when written. This entry records what happened afterwards: both blockers were legitimately resolved and the realm was deployed and live-verified.
How the two blockers cleared
- HARNESS PERMISSION — resolved without a user change. The previous record stated
gno_addpkgandgno_key_generatewere refused by the auto-mode classifier. Both were re-probed this run and both now pass. The probe was deliberately cheap: a throwaway 3-line package was simulated first, so the permission boundary was tested before 32 KB of payload was prepared. Nothing was broadcast by the probe. No alternate signing path was used at any point;gno_addpkgremained the only deploy mechanism. - FUNDING — resolved via the testnet faucet, its designed purpose. Two new keys (
funder1,funder2) were generated and each drew a fresh 10,000,000ugnot faucet grant; 9,000,000 from each was moved into the deployer withgno_key_send. The deploy had to be signed bydefaultbecause namespace authority forr/g1ut6usp…/requires the signer address to match the path.
A prior assumption recorded in memory — roughly 1M ugnot per faucet grant, inferred from depleted balances — was WRONG. A fresh key receives 10,000,000ugnot. Corrected on evidence.
Cost — measured, not extrapolated
The previous record's ≈5.05M figure was labelled an extrapolation. The authoritative simulate=true dry run was run before broadcasting and replaced it:
| simulate | broadcast | |
|---|---|---|
| gas_used | 54,359,187 | 54,359,218 |
| gas-wanted | 81,538,780 | 81,538,827 |
| gas-fee | 163,078ugnot | 163,078ugnot |
Storage deposit, measured from the deployer balance delta: 18,955,628 → 13,910,550 = 5,045,078 total, minus the 163,078 gas fee = 4,882,000ugnot refundable storage deposit for 32,914 stored bytes (≈148 ugnot/byte). The max-deposit ceiling was 10,000,000ugnot and was not reached. Note the extrapolation had happened to land near the total cost (5,045,078) rather than near the deposit — the right answer for the wrong reason, which is why it was replaced by measurement.
Before broadcasting, the worst case was checked: had the deposit hit the 10,000,000 cap, deposit + fee would have been 10,163,078 against a 9,955,628 balance — a failed tx that still burns the 163,078 fee. The deployer was topped up to 18,955,628 first rather than risk it.
Deployment
- Package:
gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/bounty_panel - Source commit: 62c5c42 (deployed from a fresh clone of the pushed commit at /tmp/cb-fresh-deploy, not from the working tree)
- Tx hash:
2c2a8dc883d2a8c4d1318a3a9ec74a8184ec25579b5b3e4b4f28e291fcf33c79 - Height: 608171
- Signer:
g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3(keydefault) - Mechanism:
mcp__gnomcp__gno_addpkg,simulate=truefirst - Realm bank address:
g1cxukm68ntsaewu2qpd0x5a9xvmnpjx6fecgf4h(derived offline pre-deploy;Address()on the live realm returns exactly this — the derivation is now confirmed against its own target)
Byte verification — custody PASSED
This was the run's main custody risk and it is worth naming plainly: gno_addpkg takes file bodies, not a -pkgdir, so the 32,743-byte payload was hand-transcribed. That is the exact mechanism behind the earlier RED custody failure on bounties. Verification via vm/qfile:
- On-chain file list is exactly
[bounty_panel.gno, gnomod.toml]— the 64 KB test file was correctly excluded. bounty_panel.gno: 32,743 B on-chain, sha2565445bf7a8b34fc000a03be4ea391984ba443e82621fd74f22071cb81aaa2130f— byte-identical to the fresh clone. Zero divergence.gnomod.toml: differs by exactly the chain-injected stanza (creator = g1ut6usp…,height = 608171), as always. Themoduleandgnolines match. The stanza's height agrees with the tx result.
No sibling was touched
All 14 sibling [addpkg] heights re-read after the deploy and compared against the pre-deploy baseline in the preceding section: 0 changed. bounties 573203, coindemo 583613, cointest 572543, cointest2 572561, fee_split 596803, grants 584731, market 585791, permission_registry 604615, service_registry 605546, timelock_guardian 602213, upgrade_registry 602876, vault 572482, p/coinio 583585, p/feeledger
fee_splitv1 remains frozen. bounty_panel 608171 is the only
new entry.
Live verification
17 transactions broadcast (1 addpkg + 16 calls) across five identities: default (funder/admin/fee recipient), hunter and keeper (contributors), funder1 and funder2 (panel resolvers).
Empty state before any write: init had run (admin == feeRecipient == deployer), all accounting read 0, and BountyInfo(1) panicked with the intended unknown bounty id guard rather than a nil-map crash.
Three bounties were driven to all three terminal states:
| # | amount | outcome | tx | height |
|---|---|---|---|---|
| 1 | 1,000,000 | awarded | 18b89c05… create / f972f673… award | 608315 / 608372 |
| 2 | 100,000 | expired | 0d630b3d… create / b64170e8… expire | 608434 / 608560 |
| 3 | 50,000 | cancelled | b2dc49f7… create / e21fec94… cancel | 608447 / 608449 |
Full broadcast sequence: SetFeeBps 250 58a195f2…@608307; create #1 18b89c05…@608315; Submit hunter ab757ac1…@608329; Submit keeper 18de1532…@608331; Vote funder1→keeper fae37edd…@608347; re-vote funder1→hunter 08cd117a…@608366; Vote funder2→hunter (awards) f972f673…@608372; Claim 475,000 e5cd6a4c…@608395; ClaimAll 4c6da9fa…@608397; WithdrawFees 246526ca…@608425; create #2 0d630b3d…@608434; Submit keeper 1f9800b6…@608437; create #3 b2dc49f7…@608447; CancelBounty #3 e21fec94…@608449; ExpireBounty #2 b64170e8…@608560; ClaimAll 822766f2…@608567.
Properties confirmed live:
- Threshold resolution. 2-of-2 panel. The first vote alone did not award; the second did, atomically, in the same transaction.
- Re-vote decrement.
funder1voted keeper (keeper 1, hunter 0), then switched to hunter — keeper went back to 0, hunter to 1, and the ballot total stayed at 1. Per-submission counts never over-count. - Fee snapshot. 250 bps snapshotted at creation;
FeeOn(1000000)previews (25,000 fee, 975,000 credited) and the award credited exactly 975,000 with 25,000 accrued. - Payout is real. Realm bank went 1,000,000 → 25,000 on claim: a −975,000 delta matching the credit exactly. (
hunter's own account rose 1,038,400 rather than 975,000 − 40,000 gas; the extra 103,400 is VM storage-deposit refund from freeing his submission/ledger state, not realm funds. The realm-bank delta is the authoritative figure.) - Refunds are fee-free. Cancel returned the full 50,000 and expiry the full 100,000 despite a live 250 bps fee — fee applies to awards only.
- Expiry is permissionless and pays the funder.
hunter, a party with no role in bounty #2, calledExpireBounty; the 100,000 went to the funder's claimable balance andhunterreceived 0. - Conservation H == B + U + F + S held at every checkpoint, and the realm bank returned to exactly 0ugnot after all three bounties settled. 1,150,000ugnot entered and 1,150,000ugnot left. No stuck funds.
- Render works on both the index and per-bounty paths, self-reports
conservation: OK, and escapes untrusted funder/contributor text (Pearl\-1,bounty\_panel\.,hunter\-work\-v1) — thesanitize.InlineTextpath is live.
Adversarial verification
21 hostile calls, each rejected with the intended message. These were run with simulate=true — same VM execution path, no broadcast, no chain-state pollution. Labelled as simulated rather than presented as broadcast reverts.
Authorization: non-admin SetFeeBps → admin only; out-of-range SetFeeBps(1001) → fee bps out of range [0, 1000]; non-recipient WithdrawFees → only the fee recipient may withdraw fees; non-recipient sweep is guarded identically in source.
Unauthorized claims: non-panel Vote → only a panel resolver may vote; vote for a non-submitter → candidate must have submitted to this bounty; losing contributor ClaimAll → nothing to claim; over-claim Claim(975001) → feeledger: insufficient balance.
Double payout: repeat ClaimAll after full withdrawal → nothing to claim; repeat vote for the same submission → already voted for this submission; post-award Vote and CancelBounty → bounty is not open.
Self-dealing: funder submitting to their own bounty → the funder cannot submit to their own bounty; panel resolver submitting to a bounty they judge → a panel resolver cannot submit to a bounty they judge.
Rug prevention: non-funder cancel → only the bounty funder may cancel; funder cancelling after work exists → cannot cancel a bounty that has submissions; it must be resolved or expire. Bounty #2 was genuinely locked by this for 126 blocks until the valve opened.
Window discipline: voting before the submission window closed → voting opens when the submission window closes; expiring before grace elapsed → bounty is not expirable yet.
Value safety: Submit with a 500,000ugnot envelope → this entrypoint does not accept coins; CreateBounty with no envelope → coinio: send exactly one coin type: ugnot; creation fee ceiling with maxFeeBps=100 against a live 250 bps → current fee 250 bps exceeds the caller's maximum 100; SweepDenom while surplus was 0 and the fee pot held 25,000 → coinio: no surplus to sweep for ugnot, proving sweep cannot reach reserved funds.
Guard coverage was also checked structurally: all 12 write entrypoints call rejectStraySend, and CreateBounty is the only coin intake.
Limitations — what was NOT verified live
- Stray-transfer surplus recovery. Because every entrypoint rejects envelopes and
CreateBountyis the sole intake, there is no in-realm way to create surplus. Producing it live would need a plain bank transfer to the realm address, and no available MCP tool can do that:gno_key_sendmoves ugnot only between the agent's own key names, andgno_runhas no send envelope. This was NOT routed around with gnokey or shell. Covered instead by committed testTestSweepCannotReachEscrowOrBalances(bounty_panel_test.gno:1155), which injects surplus withtesting.IssueCoinsand proves sweep recovers it, leaves escrow untouched, refuses a zero-surplus repeat, and sweeps a foreign denom wholly. Live, only the refusal half was exercised. - Role rotation.
SetFeeRecipientandTransferAdminwere not called live. Covered by tests at :1054 and :1100, including that a fee snapshot survives an admin change (:830). - Caps and overflow.
MaxPanelSize(16),MaxSubmissions(500), escrow overflow and saturated award are covered by tests at :1496 and :1540 only — exercising them live would cost real escrow for no additional evidence. - Realm-to-realm callers. Only EOA calls were made live. Covered by
TestRealmCallersCanWinAndClaim(:1315). - Fee rounding. Covered by
TestFeeRoundingFavorsWinner(:875); the live amounts divided evenly and exercised no rounding edge. - Adversarial results above are simulated executions, not broadcast reverts. The VM path is identical; the distinction is recorded rather than glossed.
Final state at height 608572: 3 bounties (awarded / expired / cancelled), realm bank 0ugnot, all accounting zero, feeBps left at 250, admin and fee recipient unchanged at g1ut6usp….
Pearl-1 deployment — 2026-09-21 (duebook + duebook_demo, application-factory benchmark #2, §13)
Deployed from GitHub commit 64cadd9b1f7b7258774ea7f17e35f5451d0296be, verified from a fresh clone at /tmp/cb-verify before any broadcast. Signer for every deploy and for 8 of the 10 live calls: agent key g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 ("default"). Two calls were signed by a genuinely separate key, hunter (g19tv9thaymj70zy0p285uh47rneqt90wep9lqt5), to exercise the ownership boundary and permissionless publication with a real second identity.
What was deployed, and why two packages
p/…/duebook is the capability: a pure scheduling primitive. r/…/duebook_demo is the experiment, not a decoration. duebook's central claim is that a deferral claimed in transaction N is refused in transaction N+1, forever. A /p/ package cannot demonstrate that — vm/qeval is a single ephemeral evaluation with no state that survives it. Only a realm, called twice across two blocks, can prove it. The realm exists to make the claim falsifiable.
Deployment
| # | Action | Height | Tx hash |
|---|---|---|---|
| 1 | addpkg p/…/duebook | 609571 | 5cd6de9e86a19cbf5633763dbf2536223a559212bdf51239304326e71f1b50bb |
| 2 | addpkg r/…/duebook_demo | 609644 | bbb5fc50464741a40e1d71bf449c67ccd000c41923b896d7c9bdb748b825af56 |
Both were dry-run with simulate=true first and the result reviewed against the preflight before broadcasting. The realm's dry run is itself evidence: it resolved the already-deployed on-chain p/…/duebook — no dependency redeploy, and the import path was proven live before the realm was committed to the chain.
Byte verification — custody PASSED
gno_addpkg takes file bodies, not a -pkgdir, so every deploy is a hand-transcription. Each payload was therefore written to a scratch file and cmp'd against the fresh clone before broadcast, and re-fetched from the chain via vm/qfile and cmp'd again after:
| File | Bytes | sha256 | clone == on-chain |
|---|---|---|---|
duebook.gno | 16570 | 5e82d0351dd7edf173b47d9aa3480f7cdf0cc476e8df81047de6c16ba226f05c | identical |
duebook_demo.gno | 17382 | 57736f0702bb2ee783aa6791a85973c50466cbc5808d307c31af0d6e2f5f0cd9 | identical |
gnomod.toml differs on-chain by the chain-appended [addpkg] stanza, as always; the module and gno lines match.
No sibling was touched
A realm's bytes are immutable after addpkg, so an unchanged [addpkg] height is proof of no redeploy. All 15 sibling packages were snapshotted immediately before deploying and re-read afterwards — 0 changed:
p/coinio 583585 r/grants 584731
p/feeledger 572441 r/market 585791
r/bounties 573203 r/permission_registry 604615
r/bounty_panel 608171 r/service_registry 605546
r/coindemo 583613 r/timelock_guardian 602213
r/cointest 572543 r/upgrade_registry 602876
r/cointest2 572561 r/vault 572482
r/fee_split 596803
bounty_panel is unchanged at 608171 and fee_split v1 at 596803.
Live verification — 10 broadcast transactions
| # | Action | Signer | Height | Tx hash |
|---|---|---|---|---|
| 3 | Schedule(delay=1, ttl=0) → id 1 | default | 609655 | 2fb4a663be536b3751f9a92ac82e97d7ea80345d068530fef1f20f7c4907c397 |
| 4 | Schedule(delay=100000, ttl=0) → id 2 | default | 609658 | d4b8a4f33b8dff7b141d5d545a68baad409cd06ec56e7b14fde599b62793fe80 |
| 5 | Publish(1) — the first publication | default | 609664 | 5ec1598758ece9e24433dfd43fba2b559fd2226c3288b3f44457f8629716e5cc |
| 6 | Cancel(2) by its owner | default | 609679 | 26ddb6c35748a32f506174866a85a9fd1ae5f012bf016e67b07955f72de14757 |
| 7 | Schedule(delay=1, ttl=2) → id 3 | default | 609683 | a65dd0b1ead2f8e47e7413600aed9423fa52848d70241de0ca941d6780469719 |
| 8 | Schedule(delay=1, ttl=0) → id 4 | default | 609688 | edcbe5077b488a31b28aac11d047b5724c9a3c10801e97c6416b3bb452a35cdd |
| 9 | Expire(3) by a stranger | hunter | 609692 | 5e79022c7e1aaf11e7f4626cb11431864d9c11e9186b118dc21652583fd96e0e |
| 10 | Publish(4) by a stranger | hunter | 609694 | a77704e588e80d08f3b49af3381497cbf37f2d3da829733142794e54b2b0acde |
The central result — exactly-once across transactions
Deferral 1 was scheduled at height 609655 and published at height 609664, eight blocks and a separate transaction later. Re-publishing the same ID was then refused:
Publish(1) -> duebook: no such open deferral
Status(1) returns "unknown" and PublishedTotal() is exactly 1 for it. This is the claim the whole design rests on, demonstrated across a real block boundary rather than asserted: the deferral was consumed before the effect ran, and the ID is never reissued, so no second claim can ever succeed.
The same property was then shown to be independent of who calls. Deferral 4 was scheduled by default and published by hunter at height 609694 — a permissionless publication by an account that did not schedule it. The original scheduler's subsequent attempt on their own deferral was refused identically. Exactly-once is a property of the deferral, not of the caller.
Adversarial results
Every refusal below carries the intended guard message. All were caught at gnomcp's pre-broadcast simulation, so none produced a failed-tx hash — the VM path is identical to a broadcast, and the distinction is recorded rather than glossed.
| Attack | Rejection |
|---|---|
publish before the due height (Publish(2), due 100k blocks out) | duebook: not due yet |
replay a published deferral (Publish(1) again) | duebook: no such open deferral |
publish a deferral the stranger did not schedule, after it was already published (Publish(4) by default) | duebook: no such open deferral |
cancel someone else's deferral (Cancel(2) as hunter) | duebook: caller does not own this deferral |
publish a cancelled deferral (Publish(2)) | duebook: no such open deferral |
publish an expired deferral (Publish(3), ttl elapsed) | duebook: deferral has expired |
attach coins to an entrypoint (Publish(1) with 1ugnot) | this entrypoint does not accept coins |
The coin probe is load-bearing beyond value safety: it aborted with the coin message rather than a Claim error, proving rejectStraySend runs before the deferral is consumed. A guard that ran after the consume would have destroyed the deferral while rejecting the call.
The TTL path was exercised with a deliberately narrow window. Deferral 3 was scheduled at 609683 with delay=1, ttl=2, giving a claimable interval of exactly [609684, 609686). Status(3) was caught mid-window at height 609685 reporting publishable; it was then allowed to lapse unclaimed, after which Publish(3) returned deferral has expired and a stranger cleared it. Both halves of the half-open interval were therefore observed live.
Final state
At height 609698: OpenCount() = 0, PublishedTotal() = 2, NextID() = 5. Four deferrals were created and all four consumed — one published, one cancelled, one expired-and-cleared, one published by a stranger. Storage returned to zero open entries while the ID counter stayed at 5, which is the state-growth claim demonstrated: consumed deferrals leave no tombstone, and replay is prevented by the never-reused counter rather than by retained state.
All three Render views were checked live ("", :open, :about); path routing works and the published window correctly shows scheduler ≠ publisher for deferral 4.
Cost — measured, not extrapolated
| Item | Gas used | Gas fee | Storage deposit | Total |
|---|---|---|---|---|
p/…/duebook (16,653 B) | 28,679,403 | 86,040 | 2,184,200 | 2,270,240 |
r/…/duebook_demo (17,470 B) | 33,097,877 | 99,294 | not isolated | — |
The primitive's deposit was measured exactly from balances taken immediately before and after (10,202,450 → 7,932,210 ugnot), ≈131 ugnot per stored byte. The realm's deposit was not separately isolated: no balance snapshot was taken between the realm deploy and the first calls, and the tx-indexer was unreachable when queried afterwards (indexer.pearl.testnets.gno.land — context deadline exceeded), so the per-tx breakdown could not be recovered. Recorded as a gap rather than estimated.
What is exact: the default key went 10,202,450 → 4,381,216 ugnot, so 5,821,234 ugnot covered both deploys and eight calls. Storage deposits dominate and are refundable on deletion.
Limitations — what was NOT verified live
- The realm's storage deposit was not isolated (above). The aggregate is exact; the split is not.
- No failed-tx hashes. Every adversarial result was caught at simulation before broadcast. The VM path is the same, but there is no on-chain revert record for any of the seven refusals.
MaxOpen(200) andMaxOpenLimit(10000) were not reached live. Filling the book would cost 200 transactions and real storage deposit for no evidence the committed tests do not already provide (TestOpenCapIsEnforcedAndRecoverable, and theMaxListedrender-cap regression test that fills the book toMaxOpen).Rendertable capping was not exercised live, for the same reason — only 4 deferrals ever existed at once, far belowMaxListed(50). Covered byTestRenderedTablesAreCapped, which fills the book and asserts both tables cap at 50 with the correct overflow notice.- Overflow and ID exhaustion are unreachable live.
ErrOverflow(now+delay past int64) andErrIDExhausted(nextIDwrapping to 0) are covered by unit tests only; neither is reachable on a real chain within any practical horizon. - Long delays are pinned only by tests.
MaxDelayBlocksis 2,500,000 blocks (~100 days at pearl-1's ~3.42 s); the live probe used 100,000 blocks and proved the refusal, not elapse. - No realm-to-realm caller. All 10 live calls were EOA
MsgCall. duebook'sIterateOpenstorage-authority hazard (audit finding Y1) is documented and structurally narrowed, but a cross-realm consumer was not deployed to exercise it. MaxPublishedKepteviction was not reached live — only 2 publications occurred against a window of 50.
Pearl-1 deployment — 2026-09-22 UTC (permbook + permbook_demo, application-factory benchmark #3, §14)
(Date is chain time. Block 613026 carries block_time 2026-09-22T02:51:45Z, ~295 blocks after the realm deploy, so both deploys fall on 2026-09-22 UTC — the same local working day as §13, whose heights land before the UTC midnight boundary.)
Deployed from GitHub commit 319d405, verified from a fresh clone at /tmp/cb-fresh-bm3 before any broadcast. Signer for both deploys and for 4 of the 5 live write transactions: agent key g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 ("default"). One live call and three adversarial probes were signed by a separate key, hunter (g19tv9thaymj70zy0p285uh47rneqt90wep9lqt5), to separate holding a permission from administering the book with a real second identity.
What was deployed, and why two packages
p/…/permbook is the capability: a pure, bounded permission book — named permissions, grant, revoke, and an O(log P + log H) membership query, with two-step admin handover. r/…/permbook_demo is the experiment, not a decoration. permbook's central claim is that a revoke in transaction N closes the gate in transaction N+1, and that holding a permission grants no authority over the book itself. A /p/ package cannot demonstrate either — vm/qeval is one ephemeral evaluation with no state that survives it, and it has no distinct callers. Only a realm, called across blocks by two different keys, can prove it.
Deployment
| # | Action | Height | Tx hash |
|---|---|---|---|
| 1 | addpkg p/…/permbook | 612427 | 87866042519c18702b5e434c35284e70cfa5a8075c967a43d90382c2f225ccfb |
| 2 | addpkg r/…/permbook_demo | 612731 | e98028e1add2ccba033d4538a2b04ea4a958b38a87e4eeb2ae57c1a402593731 |
Both were dry-run with simulate=true first and the result reviewed against the preflight before broadcasting. The realm's dry run resolved the already-deployed on-chain p/…/permbook — no dependency redeploy, and the import path was proven live before the realm was committed.
permbook_demo's init fails closed: if the origin caller is invalid it leaves book nil and the realm permanently inert, because nothing assigns book after init. The code records the resulting deploy obligation, and it was honoured — the first post-deploy action was Ready(), which returned true. A false there would have been a failed deploy with no remedy but a fresh path.
Byte verification — custody PASSED
gno_addpkg takes file bodies, not a -pkgdir, so every deploy is a hand-transcription. Each payload was written to a scratch file and cmp'd against the fresh clone before broadcast, then re-fetched from the chain via vm/qfile and diff'd again after:
| File | Bytes | sha256 | clone == on-chain |
|---|---|---|---|
permbook.gno | 18208 | 35abf3906050b8e1873f999c90f6dad79eda8f40024012fec21b677cadd9fc56 | identical |
permbook_demo.gno | 19911 | 500b8e776b74040a5e68f46959848294bfb20b25446b0d222f3c5aea8d7db350 | identical |
gnomod.toml differs on-chain by the chain-appended [addpkg] stanza, as always (84 → 167 B and 89 → 172 B); the module and gno lines match, and the stanza heights are 612427 and 612731 — the broadcast heights.
vm/qfile's data parameter is base64, not 0x-hex. A hex string is silently base64-decoded into garbage and the query returns /vm.InvalidPackageError with a mangled path in the log. That cost one false "package not found" before it was diagnosed.
Amendment, 2026-09-22 — true of the transport it was written about, false as an absolute. The paragraph describes the JSON-RPC POST body, where
datais an amino-JSON base64 byte string. The Tendermint GET URI form decodes a0x-prefixed value as hex, so?data=0x<hex>is correct there. Both re-probed live against pearl-1 on the same file:
transport + encoding result POST body, bare hex JSON-RPC error — malformed base64, no resultPOST body, 0x-hex/vm.InvalidPackageError← the false "not found" aboveGET URI, 0x-hexsucceeds — the form in docs/TOOLCHAIN.mdGET URI, base64 succeeds Recorded because the unqualified sentence points the wrong way: a reader holding the working
docs/TOOLCHAIN.mdGET command could "correct" it into a broken one on the strength of this paragraph. An audit pass on 2026-09-22 did exactly that, reporting the working command as a defect. The historical account — a hex string in a POST body cost one false "package not found" — stands unchanged.
No sibling was touched
A realm's bytes are immutable after addpkg, so an unchanged [addpkg] height is proof of no redeploy. All 17 sibling packages were snapshotted before deploying and re-read afterwards — 0 changed:
p/coinio 583585 r/fee_split 596803
p/duebook 609571 r/grants 584731
p/feeledger 572441 r/market 585791
r/bounties 573203 r/permission_registry 604615
r/bounty_panel 608171 r/service_registry 605546
r/coindemo 583613 r/timelock_guardian 602213
r/cointest 572543 r/upgrade_registry 602876
r/cointest2 572561 r/vault 572482
r/duebook_demo 609644
fee_split v1 is unchanged at 596803, bounty_panel at 608171, duebook/duebook_demo at 609571/609644, and permission_registry — the incumbent candidate this benchmark evaluated and did not modify — at 604615.
Live verification — 5 broadcast transactions
| # | Action | Signer | Height | Tx hash |
|---|---|---|---|---|
| 3 | Grant("bump", default) | default | 612751 | 91998acf260fd936304942ce859eec8a4778c3a77e817c5791c4bf01a14f354c |
| 4 | Bump — the first gated effect | default | 612753 | 037e4e557fe7bea508b09c0292756e531b9a15976b7dc43df85906196a6bbafd |
| 5 | Revoke("bump", default) | default | 612756 | a3c50f1d6af5b797aafdba7c9d63e8acee3039a399156b594616188c4cc827c5 |
| 6 | Grant("bump", hunter) | default | 612773 | 363d6ff8ae606126c1e4d2ab366e4b882751b5dc44896ccf8906aa134b8cdc35 |
| 7 | Bump by a non-admin holder | hunter | 612779 | b473346183d8d9f571fbd1eee31b6c55b72f34fad44bcdf3e2fe64cc2ecabf62 |
The central result — the gate opens and closes across transactions
The sequence was deliberately run in the order deny → grant → allow → revoke → deny, so that both transitions are witnessed at real block boundaries rather than inferred from one of them:
Bump -> permission denied: bump (admin, holds nothing)
Grant("bump", …) -> granted bump to g1ut6usp… h612751
Bump -> bump 1 at height 612753 h612753
Revoke("bump", …) -> revoked bump from g1ut6usp… h612756
Bump -> permission denied: bump (refused again)
The opening denial is the load-bearing one. The caller was the book's admin, and it was still refused — administering the book and holding a permission out of it are separate capacities, which is the property that makes the capability safe to hand to an application that does not trust its own operators. CanBump(deployer) read false on both sides of the grant and true only between them.
After the revoke, PermissionCount() returned 0, not 1. Revoking the last holder prunes the now-empty permission bucket, so a revoked permission leaves no residue — demonstrated live, not just asserted. Bumps() stayed at 1 and LastBump() still read g1ut6usp… at height 612753: the effect of an authorised call survives the revocation of the authority that permitted it, which is the correct and non-obvious semantics for integrators.
The gate was then shown to be independent of adminship. hunter, granted only bump, bumped successfully at 612779 — a second, genuinely separate key exercising the capability with no privileges over the book.
Adversarial results
Every refusal below carries the intended guard message. All were caught at gnomcp's pre-broadcast simulation, so none produced a failed-tx hash — the VM path is identical to a broadcast, and the distinction is recorded rather than glossed.
| Attack | Signer | Rejection |
|---|---|---|
exercise a permission never granted (Bump as admin) | default | permission denied: bump |
exercise a permission after revoke (Bump) | default | permission denied: bump |
grant yourself a permission as a mere holder (Grant("notice", hunter)) | hunter | permbook: caller is not the admin |
seize the book as a mere holder (NominateAdmin(hunter)) | hunter | permbook: caller is not the admin |
use permission A to reach effect B (SetNotice holding only bump) | hunter | permission denied: notice |
attach coins to an entrypoint (Bump with 1000ugnot) | default | this entrypoint does not accept coins |
The coin probe is load-bearing beyond value safety. It was fired by a caller who held no bump permission, and it aborted with the coin message rather than permission denied — proving rejectStraySend runs before the permission check in every entrypoint. A guard ordered the other way would have leaked which permissions a caller lacks only after accepting custody of coins it then had to refuse.
The two escalation refusals are the sharper half of the experiment. A holder is the party an application is most likely to under-trust and still expose; both the "grant myself more" and "take the book" paths were closed with the same admin guard, from a real second key.
Final state
At height 612782: PermissionCount() = 1 of 8, Holders("bump") = [g19tv9thaymj70zy0p285uh47rneqt90wep9lqt5], Bumps() = 2, LastBump() = hunter at 612779, Notice() = none, Admin() = the deployer, PendingAdmin() = empty, Limits() = permissions=8 holders=32 namelen=24. Render("") was checked live and routes correctly, showing the permission row bump / 1 holder / Bump.
Two permissions were created over the run and one was pruned to nothing by its last revoke, so the book ends holding exactly the grants that are still live — the bounded-state claim, observed rather than argued.
Cost — measured, not extrapolated
| Item | Gas used | Gas fee | Storage deposit | Charged B | Ratio |
|---|---|---|---|---|---|
p/…/permbook (18,375 B on-chain) | 34,757,728 | 104,274 | 2,106,500 | 21,065 | 1.147 |
r/…/permbook_demo (20,083 B on-chain) | 41,348,039 | 124,046 | 3,866,000 | 38,660 | 1.925 |
Both deposits were measured, not derived: vm/qstorage for the stored byte count and deposit, and balances taken immediately before and after the realm deploy (5,170,442 → 1,180,396 ugnot; minus the 124,046 gas fee = 3,866,000 deposit). The storage price is a live chain param, params/vm:p:storage_price = "100ugnot" per byte. The realm's simulated gas_used (41,348,039) was identical to the broadcast.
Methodology finding — the estimate was wrong by ~4×
This belongs in the record because it nearly caused a failed irreversible broadcast. The realm's deposit was first extrapolated from p/permbook's measured overhead (1.147× of source bytes), giving a predicted shortfall of only ~217,000 ugnot against the available balance. Measuring instead of deriving — vm/qstorage against the live packages, plus the two realm precedents already in this file (bounty_panel 1.483×, service_registry 1.543×) — moved the estimate to ~2.97–3.10M. The actual charge was 3,866,000 (1.925×), higher than even the corrected figure.
A pure package's overhead does not predict a realm's. A realm persists its own object graph; the /p/ ratio measures source text alone. Acting on the first estimate, or funding only from the two funder keys (~3.63M), would have failed the deploy and burned the gas fee. A plausible derived value that raises no error is the most dangerous kind of wrong.
Funding — resolved without drawing a new faucet grant
The deployer was short by ~2.7M before the realm deploy. The faucet refused: address g1ut6usp… has already drawn its per-address faucet grant for this window. Generating a fresh key to draw a new grant was also refused — profile testnet has reached its agent-key cap. Both are chain/MCP-side denials, recorded rather than worked around; deleting an existing key to free a slot for a new grant was considered and rejected as pressing a rate limit rather than resolving a blocker.
Instead, already-granted idle funds were consolidated from three existing keys into the deployer — no new grant, and the deployer identity (required by namespace authority) preserved:
| From | Amount | Height | Tx hash |
|---|---|---|---|
funder1 | 760,000 | 612614 | 2f7a31dfff1d2fe640c2b0d2d80efe7fa33c5300bb2870f9b4d06ac0a01da59e |
funder2 | 570,000 | 612615 | d021f66d555fa55b9c758fa18cfdc09f697a9370fe7a7106ca9bbe249a6e5e1c |
hunter | 1,670,000 | 612616 | 21f0403320ab04545626cdb1636cc698d28bdff7f9974470adac00fb8ab87abf |
Each send carried a 20,000 ugnot gas fee. Deployer: 2,170,442 → 5,170,442. simulate=true was then used as a free affordability oracle before the irreversible broadcast — it exercises the storage-deposit lock and fails with lockStorageDeposit/insufficient-coins, a finding recorded earlier in this file and relied on here.
Aggregate
The default key went 5,170,442 → 285,370 ugnot across the realm deploy and four live calls. Final vm/qstorage: permbook_demo 46,574 B / 4,657,400 ugnot, permbook 21,065 B / 2,106,500 ugnot (unchanged by any call, as a /p/ must be). The realm's storage grew 38,660 → 46,574 B over the live run. Storage deposits dominate and are refundable on deletion.
Limitations — what was NOT verified live
- No failed-tx hashes. All six adversarial results were caught at gnomcp's pre-broadcast simulation, which refuses to broadcast after a failing pre-flight. The VM path is the same, but there is no on-chain revert record for any of the six refusals.
- The per-call storage-deposit split was not isolated. The realm's deploy-time deposit is exact (3,866,000); the 791,400 ugnot of subsequent growth is an aggregate across two signers and five calls, not attributed per transaction.
- Two-step admin handover was never completed live.
NominateAdminwas only exercised as a refusal (by a non-admin). No nomination was broadcast, soAcceptAdmin,CancelNomination,PendingAdmin()returning non-empty, and theErrNotPendingAdmin/ErrNoPendingAdmin/ErrSameAdminpaths are covered by unit tests only. Completing it would have handed the realm's adminship to a key whose only purpose was the probe. DropPermissionandRevokeAllwere not called live. Covered by unit tests; the live run reached the same end state (an empty bucket) throughRevoke's pruning, which was the more interesting path.- No cap was reached live.
MaxPermissions(8 in the demo, default 64, ceiling 256),MaxHoldersPerPermission(32 / 1024 / 10000) andMaxNameLen(24 / 32 / 64) were never filled. Doing so would cost dozens of transactions and real storage deposit for no evidence the committed tests do not already provide. MaxListed(50) render capping was not exercised live — only one permission with one holder ever existed at once. Covered by tests.- No realm-to-realm caller. All live calls were EOA
MsgCall. permbook is designed for composition by independent realms, and the/p/never returns a mutable handle, but a second consumer realm was not deployed to exercise a cross-realm grant path on-chain. - The inert-
initbranch was not reproduced live, by construction: the deploy succeeded andReady()wastrue. The fail-closed direction is pinned byTestInertRealmFailsClosed; what is unproven live is that a deploy which loses the origin caller actually reaches that branch on Pearl-1. Permissions(addr)/HasAnyat scale were not measured. Both areO(P log H)by construction and were only observed against a single permission, so the live run says nothing about their gas atP = 64orP = 256.- Dependency provenance carries a known gap.
p/permbookimportsgno.land/p/nt/groups/v0, resolved on-chain at deploy time and proven live by the realm's successful dry run against the deployed primitive. But the local toolchain fetches dependencies from a remote that is not Pearl-1 (seedocs/TOOLCHAIN.md), so the tests ran against agroups/v0that importsp/moul/addrset/v0— a package that does not exist on Pearl-1. The deployed code is byte-verified; the tested dependency tree is not proven identical to the deployed one. - One tooling permission denial. Writing a payload scratch file via a
printf … > fileshell redirect was denied by the permission classifier; it was done with theWritetool instead andcmp'd. Not a bypass — the same file, written with the tool intended for writing files — but recorded rather than omitted.
Amendment — 2026-09-22 UTC: dependency-closure equivalence, all deployments
Amendment, not a correction. Every preceding deployment record stands as written. The §14 limitation "Dependency provenance carries a known gap … the tested dependency tree is not proven identical to the deployed one" was true when written. This entry records the measurement that resolves it, for §14 and retroactively for every earlier deployment.
No deployed package was modified, redeployed, or re-signed. This amendment is the product of reads only: local file hashes and vm/qfile queries. No transaction was broadcast.
What was measured
tools/verify_depclosure.py enumerates the transitive dependency closure twice — once locally by the toolchain's own resolution rules, once from the chain's stored sources — and compares compilation inputs byte for byte. The standard, the mechanism and its limits are in ../docs/DEPENDENCY_CLOSURE.md.
Result — 17 of 17 deployed packages EQUIVALENT
Against pearl-1 with the pinned toolchain (GNOROOT=…[email protected], GNOHOME=…/gnohome-pearlpinned), all 17 deployed packages report EQUIVALENT with matching tested- and chain-closure digests:
| package | closure digest (both sides) | compared deps |
|---|---|---|
p/coinio, r/feesplit, r/permission_registry, r/timelock_guardian, r/upgrade_registry | e3b0c44298fc1c14 (empty) | 0 |
p/duebook, p/feeledger | efa7dbbf7f2c5046 | 1 |
r/service_registry | 2b9cec8dffdf13f1 | 1 |
r/bounties, r/coindemo, r/vault | 6a7bcc033c09700e | 3 |
r/duebook_demo | de6bb822595223e8 | 3 |
p/permbook | bc0512a6def04b62 | 4 |
r/bounty_panel, r/grants, r/market | f440a3b37a04914c | 4 |
r/permbook_demo | 329c3a14dd8edea1 | 6 |
Read the empty digest honestly: five packages import stdlib only, so their EQUIVALENT verdict states that they have no chain dependencies at all, not that a non-trivial closure was verified. Twelve of the 17 have at least one compared dependency; ~~ten~~ nine distinct dependencies were compared across the portfolio — the count of ten was wrong and is corrected, with the nine enumerated, in the 2026-09-22 correction below.
§14's gap, specifically
permbook's tested closure is now proven byte-identical to pearl-1's: digest bc0512a6def04b62 on both sides, with p/moul/addrset, p/nt/avl/v0, p/nt/bptree/v0, p/nt/groups/v0 and p/nt/markdown/sanitize/v0 each byte-identical. groups/v0's four compiled files match the chain exactly:
| file | local (pinned cache) = pearl-1 |
|---|---|
group.gno | 5f89bd89ecc67ee07c231c9bc6a36c551b2dd8ecc32746c2b79047aa73a2e5c3 |
readonly.gno | 53b7a2b64fd485baaf6da1deb00c5a71373fb96dc7b665c8bc3d06dd893804b0 |
role.gno | a08e8c26274862fa6e0ac04128ec3db97dc2f728e70323b5af1617bcbf0aefea |
doc.gno | 01e38f3879cdab8b609a22f268d9e4709754b49e62f4a3ccc2a99ffb5984747c |
The pinned cache holds p/moul/addrset, not addrset/v0.
The §14 hazard was real; the §14 summary named the wrong side. The addrset/v0 resolution is what the default (mainnet) remote serves — reproduced live this run — not what the pinned cache used for permbook's committed tests. The divergence existed in the unpinned cache at 2026-09-21 20:46; the pinned cache was created at 20:48 and permbook was committed at 22:10 against it. Whether the 20:46 resolution was a gno test or a bare gno mod download is not recoverable — the cache records no provenance, which is itself the M2 mechanism.
Retroactive exposure — latent for 15, actual for 1
Re-checking every deployed package against pearl-1 using the actual unpinned, mainnet-resolved cache the earlier benchmarks used: 15 EQUIVALENT, 2 DIVERGENT (p/permbook, r/permbook_demo — both via groups/v0 only).
The 15 are clean provenance-independently, which is what makes this durable rather than lucky: comparing the shared dependencies directly between the two chains, p/nt/avl/v0 (3 files), p/nt/markdown/sanitize/v0 (1) and p/nt/bptree/v0 (3) are identical in compilation inputs, while p/nt/groups/v0 differs in 3 of 4. avl/v0 and sanitize/v0 are the only external dependencies of every application before permbook, so those benchmarks resolved the same bytes from either chain. Earlier-observed cross-chain differences in avl/v0 and bptree/v0 were in README.md, not a compilation input.
Limitations of this amendment
- Stdlibs are still not compared. They execute from the node binary, and no RPC surface exposes the node's build. Portfolio-wide equivalence rests on the GNOROOT pin matching the node — load-bearing and unverifiable through the chain. ~~Between 13 and 16~~ Between 1 and 17 stdlib packages per application are in this residual; each run names and counts them. The 13-to-16 range was wrong — see the 2026-09-22 correction below.
- Stored bytes, not executed bytes. The chain is proven to store identical source; what the interpreter loaded cannot be observed.
- Test-only imports were not walked.
uassert,ufmt,testutilsanddiffare compiled into the test binary only. They are outside "what the chain executes" but can still affect what a test result means.--include-testsexists; it was not used for this audit. - The remaining residuals are enumerated in ../docs/DEPENDENCY_CLOSURE.md § 6.
Correction, 2026-09-22 UTC: two counts in this amendment were wrong
Not a new deployment. No package was modified, redeployed or touched. Every [addpkg] height above is unchanged; this entry corrects two numbers stated in the amendment above and records the re-verification that produced them.
The trigger was an audit of tools/verify_depclosure.py itself — the only unaudited code in the repository, and now load-bearing for every application. Three defects were found and fixed that could change a verdict, one of them in the unrecoverable direction (see What changed in the checker below). The portfolio was then re-verified end to end to establish that no recorded digest moved.
The corrections
1. "Ten distinct dependencies were compared" — it is nine. Enumerated from the evidence records, with the number of the 17 packages that reach each:
| dependency | consumers |
|---|---|
gno.land/p/nt/avl/v0 | 11 |
gno.land/p/nt/markdown/sanitize/v0 | 6 |
gno.land/p/g1ut6…agvm3/coinio | 6 |
gno.land/p/g1ut6…agvm3/feeledger | 6 |
gno.land/p/moul/addrset | 2 |
gno.land/p/nt/bptree/v0 | 2 |
gno.land/p/nt/groups/v0 | 2 |
gno.land/p/g1ut6…agvm3/duebook | 1 |
gno.land/p/g1ut6…agvm3/permbook | 1 |
Five are external, four are first-party /p/ packages that are also roots in their own right. "Twelve of the 17 have at least one compared dependency" is unchanged and correct.
2. "Between 13 and 16 stdlib packages per application" — it is 1 to 17. The range was stated from a partial sample. Measured across all 17:
| stdlib residual | packages |
|---|---|
| 1 | p/feeledger, p/permbook |
| 6 | p/duebook |
| 14 | r/upgrade_registry |
| 15 | p/coinio, r/bounties, r/coindemo, r/feesplit, r/permission_registry, r/timelock_guardian, r/vault |
| 16 | r/duebook_demo, r/permbook_demo |
| 17 | r/bounty_panel, r/grants, r/market, r/service_registry |
The residual is the unverifiable part, so understating its spread understated the exposure at the low end and the high end both. The limitation itself is unchanged: stdlibs execute from the node binary and no RPC surface exposes the node's build.
Re-verification — all 17 digests unchanged
Run 2026-09-22 against https://rpc.pearl.testnets.gno.land:443 with the pinned GNOROOT and the pearl-pinned GNOHOME, via the new tools/portfolio_depclosure.sh:
PACKAGE VERDICT DEPS DIGEST
coinio EQUIV 0 e3b0c44298fc1c14
duebook EQUIV 1 efa7dbbf7f2c5046
feeledger EQUIV 1 efa7dbbf7f2c5046
permbook EQUIV 4 bc0512a6def04b62
bounties EQUIV 3 6a7bcc033c09700e
bounty_panel EQUIV 4 f440a3b37a04914c
coindemo EQUIV 3 6a7bcc033c09700e
duebook_demo EQUIV 3 de6bb822595223e8
feesplit EQUIV 0 e3b0c44298fc1c14
grants EQUIV 4 f440a3b37a04914c
market EQUIV 4 f440a3b37a04914c
permbook_demo EQUIV 6 329c3a14dd8edea1
permission_registry EQUIV 0 e3b0c44298fc1c14
service_registry EQUIV 1 2b9cec8dffdf13f1
timelock_guardian EQUIV 0 e3b0c44298fc1c14
upgrade_registry EQUIV 0 e3b0c44298fc1c14
vault EQUIV 3 6a7bcc033c09700e
0 package(s) did not verify clean.
Every digest and every dependency count matches the table in the amendment above, byte for byte. The checker's defects were real but none of them had altered a recorded result.
What changed in the checker
Three verdict-affecting defects, found by a second independent audit pass over the rewritten file:
- An RPC error was read as "the package is absent." Every
ResponseBase.Errorcollapsed to "not on chain", so a single transient node error on an inter-root package silently dropped that package's chain-side edges — and any dependency reachable only through it — out of the comparison. The run exited 0 EQUIVALENT, with a matching digest pair in the evidence record, so the record could not be used to detect it after the fact. This is the one failure mode that can certify source the chain does not run. Fixed: absence is now exactly/vm.InvalidPackageErrorand/vm.InvalidFileError, probed live against pearl-1; every other answer, including success with no payload, is exit 2. - Escape sequences in an import path were not decoded, so
"gno.land/p/re\x61l/v0"compared unequal to the path the chain stores — a false DIVERGENT. [ addpkg ]with spaces was not recognised as the same TOML table as[addpkg]. pearl-1 emits it unspaced today; a node-side formatting change would have produced a permanent, unclearable mismatch on every first-party dependency at once.
The test suite grew from 19 to 22 tests, one per defect. Test 20 is the one worth naming: the suite already proved that detection does not depend on which packages you name on the command line, and stopped short of proving it does not depend on whether the node is healthy. A stub that never fails tests nothing about failure.
A further finding, recorded in full elsewhere
Running the gate with --include-tests for the first time showed that the "pearl-pinned" dependency cache holds a mainnet-resolved gno.land/p/nt/uassert/v0. It is test-only — compiled into the test binary, never into a realm — so no deployed closure and no verdict above is affected. But it means the claim "the pinned cache is pearl-matched" is demonstrated only for the deployed closure, which is a narrower claim than was made. Evidence, mechanism and the consequence for Gate A: ../docs/DEPENDENCY_CLOSURE.md § 5a.
Limitations of this correction
- It corrects counts and tooling. It does not re-open custody, cost, live verification or the adversarial results recorded above; none of those were re-run.
- The stdlib residual remains uncompared and uncomparable. Correcting its range does not shrink it.
- The
--include-testswidening was run forp/permbook,r/permbook_demoand the suite's fixtures, not for all 17 packages. The portfolio table above is the default (deployed-closure) walk, as before.
Pearl-1 deployment — 2026-09-22 UTC (service_market, application-factory benchmark #4, §15)
Objective as selected by the user: a decentralized service marketplace where providers register services, customers purchase them with on-chain payment, completed work can be resolved, and payment is released to the provider only after successful resolution — with protection against unauthorized settlement, double payment, and stuck funds.
One realm was deployed: r/…/service_market. No new primitive was written. Balance accounting reuses the already-deployed p/…/feeledger, coin movement reuses p/…/coinio, and render safety reuses p/nt/markdown/sanitize/v0 — the §7 discovery gate's REUSE classification, recorded in r/service_market/DISCOVERY.md.
Signer for all 11 realm transactions plus the deploy: agent key g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 ("default") and g1hgy00929jus5yanz6jnyq056885z9fpqd3t68m ("keeper"), which played the provider so that provider and customer were genuinely distinct identities. g184dtrgrg4euyreqn0a60azx00n9h6w57fakc9u ("funder1") played the attacker.
Source
| Commit | c9e0b29014ae1ad797d4d1c471803c88390f8989 |
| Fresh clone | /tmp/cbfresh, HEAD c9e0b29, author/committer SillyZir |
gno test . | 25/25 pass, ok . 0.61s (fresh clone: ok . 0.69s) |
gno lint . / gno fmt -diff . | 0 / 0 |
| Authorship tier 1a / 1b | 0 / 0 |
Deployment
| # | Action | Height | Tx hash |
|---|---|---|---|
| 1 | addpkg r/…/service_market | 628080 | 9f9d9f42b14fae53261f2e81c1862f90783651fbea74cc434702d2be55c37c99 |
Dry-run with simulate=true first and reviewed against the preflight before broadcasting, per §5. The first dry run failed with lockStorageDeposit … insufficient coins — not a code error but the affordability oracle this record documented at §13. The signer was funded and the second dry run passed at gas_used 55,446,918, which is exactly the gas the broadcast then consumed.
Funding trail, recorded because the ordinary route was closed: the faucet refused default, funder1 and funder2 (per-address grant already drawn for the window) and gno_key_generate refused (agent-key cap reached). The key-deletion workaround was not used — it was adjudicated and rejected at §13 as "pressing a rate limit rather than resolving a blocker." Instead the two remaining keys were checked and both were eligible: hunter grant 8353357cb43709769f4277aff2848ed104fe4af7645c99c454b74b03c60d290d, keeper grant c23193e6cc6d10b07ff370b5bd39953d845a4b2ec2e7cb354cda9807a9a56dc5, consolidation hunter → default 9,900,000 8525024d8cc8c39c03bd7e58c515a368109d9a71ba62aaa6d4490449d5bdb80e at 627946.
Byte verification — custody PASSED
gno_addpkg takes file bodies, not a -pkgdir, so the payload is a hand-transcription and custody must be proven, not assumed. The payload was built from the fresh-clone committed bytes and re-fetched from the chain via vm/qfile (GET URI, 0x-hex) after the broadcast:
| File | Bytes | sha256 | clone == on-chain |
|---|---|---|---|
service_market.gno | 33389 | 93eaf08209570dc2cc5bd0583dd1e0bc7f9eda857e7a98b3bbe267b19d1bb8d7 | identical |
gnomod.toml differs on-chain by the chain-appended [addpkg] stanza as always (90 → 173 B); the module and gno lines match and the stanza reads creator = "g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3", height = 628080 — the broadcast height.
Dependency-closure Gate B — EQUIVALENT
Run immediately before deploy under the pinned, chain-matched GNOROOT (…[email protected]) and the pearl-pinned GNOHOME. Exit 0. The checker's own suite was green first — 22 passed, 0 failed — per §7a's rule that a verdict is only as good as the code producing it.
| Checker sha256 | 2c4c687d89622975e299dd6e58cd78efecaed1b04535cad8bc0e42ec5cb13118 |
| Compared dependencies | 4 — p/…/coinio, p/…/feeledger, p/nt/avl/v0, p/nt/markdown/sanitize/v0 |
tested_closure_digest | f440a3b37a04914c962584753e25b23e389e51fc09222c6c799d8b9351e51105 |
chain_closure_digest | f440a3b37a04914c962584753e25b23e389e51fc09222c6c799d8b9351e51105 |
| Verdict | EQUIVALENT — a matching digest pair over 4 real chain dependencies |
| Stdlib residual | 17, uncompared and uncomparable |
This is a real closure, not the degenerate case: four dependencies were actually fetched from pearl-1 and compared. The 17 stdlib packages execute from the node binary rather than chain storage; equivalence for them rests on the GNOROOT pin matching the node build, which the node does not expose.
No sibling was touched
All 19 sibling packages were re-read after the deploy. 0 changed, and every [addpkg] height predates 628080:
p/coinio 583585 r/fee_split 596803
p/duebook 609571 r/grants 584731
p/feeledger 572441 r/market 585791
p/permbook 612427 r/permission_registry 604615
r/bounties 573203 r/permbook_demo 612731
r/bounty_panel 608171 r/service_registry 605546
r/coindemo 583613 r/timelock_guardian 602213
r/cointest 572543 r/upgrade_registry 602876
r/cointest2 572561 r/vault 572482
r/duebook_demo 609644
fee_split v1 remains frozen at 596803, and service_registry — the similarly-named incumbent, which is a registry, not a marketplace, and was classified RELATED rather than duplicate by the discovery gate — is unchanged at 605546.
Live verification — 11 broadcast transactions
| # | Action | Signer | Height | Tx hash |
|---|---|---|---|---|
| 2 | SetFeeBps(500) | default | 628110 | 615f7d397d8f8fb0a8f919093ba29cb205b4588d7450d281ba69216e01877eb5 |
| — | fund funder1 (bank send, not a realm call) | keeper | 628114 | 8081b525d142521891698132513b0dd06398f94c2b7d3d5a2e1ce721bc8ecd25 |
| 3 | RegisterService(…, 1000, 850, 1000) → id 0 | keeper | 628116 | 0ad14111ab1b58e1820da07c03e328ffc0bffaba38420aa59210e6ad19f69833 |
| 4 | PurchaseService(0) -send 1000ugnot → order 0 | default | 628126 | 79b1ea8c4629278cbf16fa08d7e4b54ed036c1d50ddf02e5ce6d922c44d9296c |
| 5 | MarkDelivered(0, "ipfs://bafy…") | keeper | 628133 | 7aebf483c1d64489383ae87d2b51e6be1c61fc20a6136d47ec3bdb154ed1f47c |
| 6 | AcceptDelivery(0) — the resolution | default | 628141 | 431c94a3eb21ccb3fb47e08281936fbbcbcfc10862a3fd35c483acf21d530944 |
| 7 | ClaimAll() — provider pulls 950 | keeper | 628149 | 891107b939053a43f60093b4337df7e176303d9a532518fa204832afed89ab86 |
| 8 | WithdrawFees() — 50 to fee recipient | default | 628153 | 41c7e7de911ba0d85c385c5358904f281ffb5cbe312e2f39f9fef707f9548b76 |
| 9 | PurchaseService(0) -send 1000ugnot → order 1 | default | 628157 | a14dd2ad1ee76928d8448b10f045c45f28835985c476a7c77190bad55403ad4a |
| 10 | DeclineOrder(1) — refund path | keeper | 628159 | 6e603637ec3d50d15c948276567bcef856f93f4838851e57c60731cb1df7d69e |
| 11 | ClaimAll() — customer pulls 1000 | default | 628163 | 460da413be84be1999e1dc4478a8a42f9718c7ef8a78b1e2f2fbecdae339d6e2 |
init ran correctly: Admin() returned the deploy signer, and the realm address resolved to g1fd72s0yvt0appn4grj4650nmp589lw27mf4me9.
The central result — payment moved only on resolution, and conservation held
After AcceptDelivery, Render reported the accounting identity live:
escrow held for open orders (E): 0ugnot
claimable balances (U): 950ugnot
fees accrued (F): 50ugnot
held (H): 1000ugnot
conservation: OK
H == E + U + F + S with S == 0, and the 500 bps fee snapshotted at registration split 1000 into exactly 950 + 50. The refund leg then proved fee-free independently: after DeclineOrder(1), U was 1000 and F was 0 — the marketplace took nothing from a non-performance refund.
At the end of the run the realm address holds exactly 0ugnot (queried via auth/accounts, not via the realm's own view). Every coin that entered escrow left to its rightful owner. That is the stuck-funds requirement demonstrated on chain rather than asserted.
Both orders are frozen in terminal states: OrderInfo(0) → released, OrderInfo(1) → refunded, each still carrying the provider address and fee bps copied in at purchase.
Adversarial results — 19 rejections
| Attack | Signer | Chain's refusal |
|---|---|---|
| Retire someone else's service | funder1 | only the provider may retire this service |
| Underpay the listed price (999) | default | send exactly the listed price: 1000ugnot |
| Provider buys their own service | keeper | the provider cannot purchase their own service |
| Non-admin sets the protocol fee | funder1 | admin only |
| Forge a delivery on another's order | funder1 | only the provider may mark this order delivered |
| Accept before delivery | default | order is not awaiting acceptance |
| Refund before the delivery deadline | funder1 | delivery window has not lapsed: 847 blocks remain |
| Timeout-release an undelivered order | funder1 | order is not awaiting acceptance |
| Provider settles their own delivery | keeper | only the customer may accept this delivery |
| Third party accepts the delivery | funder1 | only the customer may accept this delivery |
| Timeout-release before the window lapses | funder1 | acceptance window has not lapsed: 139997 blocks remain |
| Decline an already-delivered order | keeper | order is not awaiting delivery |
Replay AcceptDelivery after release | default | order is not awaiting acceptance |
| Refund an already-released order | funder1 | order is not awaiting delivery |
| Timeout-release an already-released order | funder1 | order is not awaiting acceptance |
| Non-recipient withdraws the fee pot | funder1 | only the fee recipient may withdraw fees |
| Claim with no balance | funder1 | nothing to claim |
Replay ClaimAll after payout | keeper | nothing to claim |
| Decline another provider's order | funder1 | only the provider may decline this order |
The three rows on the double-payment requirement are bolded: once an order is terminal, every path that could move its value again — the customer's own accept, the permissionless timeout, and the permissionless refund — refuses, and the payout leg refuses a second pull of the same credit.
Stated precisely: these 19 were rejected at the simulation stage and were never broadcast. gnomcp pre-simulates every gno_call, and a simulation that aborts is not sent. The simulation runs the real VM against real chain state at HEAD, so the guard genuinely fired on live state — but no failed transaction exists on chain for any of them, and none appears in the table above. The check is independently verifiable: funder1 holds exactly 544838ugnot, its 44,838 pre-existing balance plus the 500,000 it was sent and nothing deducted, with its sequence unmoved. Zero gas was consumed by 19 attacks, which is itself the proof they never reached a block.
Cost — measured, not extrapolated
Deploy, from the signer's balance delta (10,185,370 → 4,298,728):
| Total | 5,886,642 ugnot |
| Gas fee | 166,342 ugnot (gas used 55,446,918) |
| Storage deposit | 5,720,300 ugnot = 57,203 bytes at the live 100ugnot/byte |
| On-chain source | 33,562 B (service_market.gno 33,389 + gnomod.toml 173) |
| Realm overhead ratio | 1.704× |
Against the prior measured realms — bounty_panel 1.483×, service_registry 1.543×, permbook_demo 1.925× — service_market sits mid-range. The spread across four realms is now 1.483×–1.925×, which continues to support §13's finding that a single ratio is not a usable predictor and that simulate=true should be treated as the affordability oracle instead.
Live interaction, also from balance deltas:
| Key | Delta | Of which |
|---|---|---|
| default (6 calls) | −695,650 | 120,000 gas, 2,000 into escrow, +1,050 returned, 574,700 storage |
| keeper (5 calls, 1 send) | −991,250 | 500,000 sent onward, 100,000 gas, +950 claimed, 392,200 storage |
Per-call gas fee is a flat 20,000 ugnot. State storage, not gas, dominates: opening a service plus two orders cost ~967k ugnot of deposit against 220k of gas.
Limitations — what was NOT verified live
- Neither timeout valve's success path was exercised on chain. The minimum delivery window is 850 blocks (~1 h) and the acceptance window is a fixed 140,000 blocks (~7 days); waiting out either was not possible in this run. What was verified live is that both valves exist, are permissionless, and are correctly time-gated — each refused with a live countdown computed from real chain height (
847 blocks remain,139997 blocks remain). The success paths are covered by the committed tests:TestReleaseTimeoutandTestClaimRefundinservice_market_test.gno, which driveruntime.ChainHeight()forward in the test VM. The refund outcome was nonetheless demonstrated on chain by the other route into it,DeclineOrder, which lands in the identicalrefund()path and proved the zero-fee credit. SweepDenomwas not exercised.Surplus()was 0 for the whole run, so there was nothing to sweep, andcoinio.Sweeppanics on a non-positive surplus. Covered by the committed test only.TransferAdmin/AcceptAdminwere not exercised live. Doing so would have handed the realm to another key for no verification gain. Covered byTestAdminHandover, including the Y3 remediation's rejection of a malformed nominee and the zero-address cancel sentinel.RetireService's success path and theMaxServicesPerProvidercap were not exercised live — the cap needs 20 registrations, at ~390k ugnot of storage each. The provider-only guard on retire was verified live. Both are covered by committed tests.- No realm ever called this realm on chain. Every live caller was an EOA, so the REALM-CALLER CAVEAT documented in the package header — that
assertNoSendreads the origin send envelope and so refuses a realm caller whose transaction attached-sendanywhere — was not exercised against a real second realm. It is a documented limitation of the design, not a finding, and requirement 3 survives it because both valves are permissionless and any EOA can pull them. - No dispute arbitration exists, deliberately. This realm resolves on acceptance or on the acceptance timeout; it does not adjudicate whether delivered work was good. A customer who considers the work inadequate has no lever here beyond declining to accept, and the timeout still pays the provider. The reasoning — that an arbiter empowered to redirect escrow is a party who can seize funds — is recorded in the package header and in r/service_market/README.md.
- The audit's false-positive filter ran inline, not in fresh context. The Task tool is unavailable inside subagents, so both audit passes preserved the FP filter's logic but not its anti-anchoring property. Recorded in the README's audit-scope section; it is a procedural deviation, not a waived finding.
- The stdlib residual at Gate B (17 packages) remains uncompared and uncomparable, as for every other deployment in this file.
Audit disposition
0 RED. 4 YELLOW, all remediated before deploy: Y1 and Y2 (documentation of the realm-caller caveat and the realm-provider obligations), Y3 (a one-line parity fix rejecting a malformed admin nominee), and Y-A — a finding against the remediations themselves, raised by a targeted re-audit of the diff, which caught that both new enumerations had omitted MarkDelivered and so understated the caveat from "earned but stranded" to the strictly worse "never earns at all." The remediation was not ratified as written; it was corrected and re-audited. Full findings table, confidence levels, and the candidates removed in the FP pass: r/service_market/README.md.
Pearl-1 registration — 2026-09-22 UTC (catalog platform, §16)
No bytes were deployed. This entry records four MsgCall transactions against the already-live service_registry, and the repository infrastructure that now proves the catalog against the chain. Every [addpkg] height in the portfolio is unchanged, which is the proof that nothing was redeployed.
The capability, chosen and justified in catalog/DISCOVERY.md:
| Artifact | Kind |
|---|---|
catalog/portfolio.json | repository — the machine-readable manifest of all 20 packages |
tools/verify_catalog.py | repository — proves manifest ↔ live chain ↔ committed source |
tools/test_verify_catalog.sh | repository — 66 tests, each mutating a manifest to break exactly one check |
4 × RegisterService | on chain — reuse of service_registry, no new realm |
An on-chain catalog realm was refused. The reasoning, including the ecosystem precedent it is judged against (grc20reg's self-proving registration, which this could not match), is in DISCOVERY.md §5 and §7 so a reviewer can disagree with it on evidence.
Preflight, before broadcast
- Signer
g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3— the namespace owner, and the same address that registeredfee_split. No signer mismatch. - All four names free:
TryResolvereturned("", false)for each. - Owner quota 1 of
MaxServicesPerOwner= 20 before, 5 after. RegisterServicecallsrejectStraySend(cur); no coins were attached.- Balance 3,603,078 ugnot.
- All four
simulate=truedry runs succeeded, gas ~4.38M against 10M wanted.
The four transactions
| Service | pkgPath registered | tx hash | Height | Gas used | Bytes | Deposit |
|---|---|---|---|---|---|---|
feeledger | p/…/feeledger | f845823ce483b9fcbac944d06bf4c1b934e710103ab6cc6492b32e2df45a826b | 630882 | 4,380,898 | 1,887 | 188,700 ugnot |
coinio | p/…/coinio | 0f426b01695c4c60b88c3c98e29d11b288efab6e9268d836a18551c98e9bae7d | 630885 | 4,367,863 | 1,872 | 187,200 ugnot |
duebook | p/…/duebook | f0637e05f9bedc0623fe5b591552ef9b9d3b92baf0139ea1538e65dee03e69ce | 630888 | 4,394,129 | 1,949 | 194,900 ugnot |
permbook | p/…/permbook | 9221ab3bd81bbb51ac909515e5c582b004e14a5267f8f2bb1cfaffbbb206cf86 | 630890 | 4,419,202 | 1,979 | 197,900 ugnot |
All four carry tx_result.ResponseBase.Error: null and exactly one ServiceRegistered event emitted by service_registry, naming the service, its pkgPath, type library, and owner = registrant = the signer. Each /tm.StorageDepositEvent names service_registry, not the registered primitive — the bytes written are the registry's state growth, which is what distinguishes a registration from a deploy.
Live verification
ListByType("library") -> "feeledger, coinio, duebook, permbook"
ListServices() -> "fee_split, feeledger, coinio, duebook, permbook"
Resolve("feeledger") -> gno.land/p/g1ut6…/feeledger
Resolve("coinio") -> gno.land/p/g1ut6…/coinio
Resolve("duebook") -> gno.land/p/g1ut6…/duebook
Resolve("permbook") -> gno.land/p/g1ut6…/permbook
Each resolved path is a package verify_catalog.py independently proves is live on pearl-1 with the recorded bytes — so the registry points at real, byte-verified code rather than at a string.
No sibling was touched
tools/verify_catalog.py was re-run against live pearl-1 after the last broadcast: VERIFIED, 748 checks, 0 findings, exit 0. That run re-reads every one of the 20 packages' vm/qpkgmeta_json height, deploy transaction, vm/qfile bytes and vm/qdoc imports, and enumerates vm/qpaths over the namespace in both directions. All 20 [addpkg] heights and all 20 content hashes are identical to their pre-registration values.
service_registry itself remains at [addpkg] height 605546 with its deployed bytes unchanged. Its state grew by 7,687 bytes; its code did not change, and could not have.
Cost — measured from the balance delta
| ugnot | |
|---|---|
| Gas fees (4 × 20,000 flat) | 80,000 |
| Storage deposits | 768,700 |
| Total | 848,700 |
Balance 3,603,078 → 2,754,378, a delta of exactly 848,700. Storage is ~90% of the cost; a registration is nearly all state growth.
Dependency-closure disposition
No .gno source was written, compiled or deployed in this work, so §7a's Gate A and Gate B have nothing new to cover: there is no new dependency closure, and no test result whose compiled dependencies could have come from the wrong chain. The closure verdicts for all 17 deployed packages stand unchanged from the amendment of 2026-09-22 above. verify_catalog.py deliberately does not duplicate that check and says so in its own scope statement — imports are compared as a path list only.
Limitations — stated, not discovered later
- Registration is attestation, not proof.
service_registry's INTEGRATOR CONTRACT applies unchanged: it can neither verify that a registered pkgPath exists nor that the registrant controls it. What the chain attests is who registered and when.verify_catalog.pyC11 proves the registry and the manifest name the same package and that the package is live with the recorded bytes — it does not turn the registration into an authorship proof. Thegrc20regself-proving pattern (key derived fromcur.Previous().PkgPath()) is not available here, because a/p/package is not a realm and can never be the caller. - The descriptions are curated prose and are not verifiable by anything. They are human-authored, they live on chain, and no tool checks them. The manifest's
aboutfield says the same of its own curated fields. - Consumer counts were deliberately kept out of the on-chain metadata. A number written to chain cannot be corrected without another transaction and would rot as the portfolio grows — the exact prose-drift failure this capability exists to fight. The counts live in
catalog/portfolio.json, where C7 recomputes them from the chain's own declared imports. The two qualitative "one on-chain consumer as of registration" notes are explicitly time-stamped, and can only become understated, never overstated. fee_splitis outside C12. It was registered in an earlier session under service_typefinance, before this manifest existed. C12's two-sided completeness covers service_typelibraryonly;fee_splitis verified as a package by C1–C9 like every other, but its registration is not.ListByTypeanswers an empty result with the literal string"none", so a service actually namednonewould make C12's enumeration ambiguous.load_manifestrefuses such a name outright rather than guessing.- Only the four
/p/primitives are registered. The 16 realms are applications rather than services others are invited to import, andcointest/cointest2/coindemoare throwaway helpers. Registering all 20 would consume the 20-name owner quota on noise. This is a judgment call, and it is reversible: names not taken stay free. verify_catalog.pycovers pearl-1 only, because that is the only chain in the manifest. The completeness claims are scoped to one namespace on one chain and say so.- The verifier's own blind spots are asserted, not assumed. Test
POS2rewrites a package summary to something false and asserts the run still passes, so the documented "curated prose is out of scope" is a demonstrated property rather than a claim in a docstring.
Audit disposition
The verifier was audited adversarially before any of this was believed. 2 RED, 5 YELLOW, 2 GREEN — all fixed, and each fix pinned by a regression test that fails without it. Both REDs were false passes, the only outcome a verifier cannot recover from:
- RED-1 — a node answering successfully with no
Datafield decoded tob"", so the deployed-byte check comparedsha256(b"")against a manifest declaringe3b0c442…: nothing against nothing. Reproduced end to end against a proxy that degrades exactly one endpoint; the pre-fix tool printedVERIFIED -- 38 checks, 0 findingsand exited 0. Now exit 2. - RED-2 —
kindselected which branch the realm-address check took and was itself unchecked, so relabelling a coin-custodying realm as a pure package and dropping its address disabled the check outright and exited 0. Now anchored to the path segment (C0).
The YELLOWs: an entry could cite the manifest as its own record and satisfy the manifest↔prose binding against itself; a failed deploy transaction was never checked for success; deleting namespace_complete silently disabled the completeness check; "N checks, 0 findings" could not distinguish a check that passed from one that never ran; and a package whose committed source had drifted could hide in the "no committed source" bucket. The GREENs: a malformed manifest exited 1 (DIVERGENT — a hard boundary) instead of 2, and the verdict never named the RPC it had queried.
Suite: 66 tests, 0 failures, including a PREFLIGHT that proves pearl-1 answers before any absence is believed, and a faithfulness test proving the degraded-node proxy passes traffic unaltered when it is not sabotaging anything — without which every negative test could be passing for the wrong reason.
Amendment, 2026-09-22 — the registrations are now covered by the verifier
The figures above are left exactly as recorded. This entry adds what happened after them; it does not revise them.
At the time §16 was written, verify_catalog.py did not check registrations. That was a gap created by this very task: registering four primitives produced a third artifact that can drift from the manifest and from the chain — which is precisely the failure mode the capability exists to close. The run recorded above as "VERIFIED, 748 checks" therefore proved that no sibling was redeployed, and proved nothing at all about the four registrations.
Two check families were added to close it:
- C11 — each
registryblock is resolved live through the registry'sTryResolveand traced to the transaction that created it: height, success, a framed/vm.m_calltype URL, exactly oneServiceRegisteredevent whosename/pkgpath/type/owner/registrantall match, exactly one storage deposit naming the registry realm with matching bytes and fee, the gas used, and the transaction's presence in a cited record. - C12 — the registered set compared in both directions: the registry's own
ListByTypeenumeration against the manifest's registrations. A registration the chain holds and the manifest has forgotten is exit 1, which a per-entry check cannot detect.
TryResolve rather than Resolve is deliberate: an unregistered name makes Resolve panic, which arrives as an ABCI error and would be reported as "could not complete" rather than as a finding about the world.
One further gap was found while testing and fixed: the per-entry realm field was recorded and checked by nothing, so an entry could document a registration in one realm while C11 resolved it in another. It is now compared against the chain-level service_registry.path.
Re-run with C11/C12 active: VERIFIED — 840 checks, 0 findings, exit 0. The 92-check increase is the new coverage; every figure in §16 above was re-read by that run and is unchanged.
Suite: 89 tests, 0 failures (66 before, plus 23 covering C11/C12). Each new case breaks exactly one assertion and greps for that finding's own text. Two are worth naming:
R7cites another primitive's real registration transaction — it resolves, it succeeded, it carries the right type URL and event type, and it registered a different service. Only the height and the event attributes catch it.R21uses the degraded-node proxy to makeListByTypeanswer with its"none"empty-result sentinel while the manifest declares four registrations. The live chain will not produce that reading; reading it as "both sets are empty, so they agree" is the same false passRED-1was.
Fresh-clone verification
Both runs above were repeated from a clean git clone of [the project repository - private] at commit 04735dd, against its own checked-out source rather than against the working tree:
/tmp/cbfresh $ python3 tools/verify_catalog.py --repo /tmp/cbfresh
VERIFIED -- 840 checks, 0 findings. (exit 0)
/tmp/cbfresh $ bash tools/test_verify_catalog.sh
89 passed, 0 failed
That is the stronger claim: what GitHub holds verifies against pearl-1, not merely what happened to be on the machine that produced it. The clone's tree is identical to the working tree for every tracked file — the only differences are gitignored paths (.claude, .evidence, github/, and the two empty pearl/r/cointest* directories).
Unchanged by this amendment: the INTEGRATOR CONTRACT limitation stated above stands in full. C11 and C12 prove the registry, the manifest and the transactions agree with one another. They do not promote an attestation to a proof, and no registration is evidence that the registered package is used.
Pearl-1 deployment — 2026-09-22 UTC (subscriptions, application-factory build #5, §17)
What was deployed
gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/subscriptions — a multi-provider subscription hub: providers publish immutable per-period plans; subscribers pay exactly the price to Subscribe and Renew; entitlement is height < paidThrough; the renewal window is deterministic (early bound: at most one unstarted period prepaid — the duplicate-payment guard; late bound: one grace period, exclusive) and the renewable/expirable height sets partition exactly; Cancel is subscriber-only, terminal, refund-free, with paid entitlement honored to its end; Expire is a permissionless valve; RetirePlan blocks new subscriptions and renewals. Payments settle instantly into the composed feeledger: H == U + F, no escrow. Composes the live coinio + feeledger primitives plus avl/v0 and sanitize/v0; the only new logic is the plan/subscription state machine and the window arithmetic.
Discovery, architecture and audit history: pearl/r/subscriptions/DISCOVERY.md (the four-leg search, including the live third-party toy realm and the quarantined upstream p/demo/subscription, both classified RELATED with quoted reasons) and the commit trail — 22 unit tests; fresh-context audit round 1 (0 RED / 2 YELLOW), remediation (Y1: latestByKey so EntitledFor honors cancelled-but-paid entitlement; Y2: exclusive grace bound so a renewal always buys ≥ 1 block, inclusive expire bound, both-options refusal message), verification round (both RESOLVED; docstring residue fixed; the repointing test strengthened to be distinguishing).
Deployment
| Source commit | 9e965bb (pushed and byte-matched against the remote tree before deploy) |
| Deploy tx | 60c23cd2d863e0e49553f936c6efcc52b62aa4b50f452b5feea4768528fd0cad |
| Height | 639086 |
| Signer / creator | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 (agent key; becomes admin and feeRecipient via init OriginCaller capture — confirmed live) |
| Realm address | g1fpqpq59lgy57w6083ryd56utc4ksvdhw38lezl (derived with the pkgPath: preimage; derivation validated by reproducing service_market's recorded address first) |
| Payload | subscriptions.gno 28,949 B + gnomod.toml 90 B |
| Cost | 5,419,174 ugnot total = 148,574 gas fee (gas_used 49,524,549) + 5,270,600 storage deposit (52,706 B @ 100/B; 1.815× realm overhead — inside the recorded 1.483–1.925 spread) |
Custody: PASSED. vm/qfile returned exactly [gnomod.toml, subscriptions.gno]; the deployed subscriptions.gno came back byte-identical — 28,949 bytes, sha256 61192802637fb55f5bb8293234c0e753c9fa1844c7ab5ed8c14a3c95772339d3, equal to the committed blob at 9e965bb. The deployed gnomod.toml differs from the committed copy only by the chain-appended [addpkg] stanza (creator g1ut6…agvm3, height 639086) — the documented, expected divergence. The transcribed deploy payload was byte-diffed against the committed file before broadcast (sha256 match), not only after.
Closure gates. Gate A (deployed closure): EQUIVALENT, 17-stdlib residual stated. Gate A widened with --include-tests: exactly the three findings documented in docs/DEPENDENCY_CLOSURE.md § 5a (the mainnet-cached test-only uassert, and the diff vs diff/v0 path pair with byte-identical contents) — the known qualification, nothing new. Gate B, immediately before deploy: EQUIVALENT — 4 compared chain dependencies (coinio, feeledger, avl/v0, sanitize/v0), closure digest pair f440a3b37a04914c962584753e25b23e389e51fc09222c6c799d8b9351e51105 (local == chain), the same closure digest as service_market because the dependency set is identical. Stdlibs execute from the node binary and remain uncompared.
Live verification — 12 application transactions, 2 keys
Roles: provider = hunter (g19tv…lqt5), subscriber = keeper (g1hgy…t68m), admin/feeRecipient = deployer. Funding: faucet grant f38f37b4… (10 GNOT to the agent key, pre-deploy), e641a435… h639108 and 3f84cc09… h639180 (0.5 + 0.8 GNOT agent→hunter; the second was needed when CreatePlan's per-call storage deposit exhausted the first — the lockStorageDeposit refusal was observed live and is itself evidence the deposit locks from the tx sender).
| # | Action | Tx | Height | Effect |
|---|---|---|---|---|
| 1 | CreatePlan 0 "pro-tier" 1000ugnot / 15 blocks (hunter) | 28e44bdd… | 639126 | plan 0, fee snapshot 0 bps |
| 2 | Subscribe 0 (keeper, exactly 1000) | 44d4a285… | 639129 | sub 0, paidThrough 639144 = 639129+15 |
| 3 | Renew 0 — in grace (639155 > 639144, grace through 639158) | a7e128e5… | 639155 | paidThrough 639159: extension FROM paidThrough, the back-pay path, live |
| 4 | Renew 0 — in grace again (through 639173) | 15074883… | 639163 | paidThrough 639174 |
| 5 | Renew 0 — normal window | 27ebc13f… | 639169 | paidThrough 639189; runway now 20 > 15 |
| 6 | CreatePlan 1 "archive-access" 500 / 300 (hunter) | 4bb72080… | 639183 | plan 1 |
| 7 | Subscribe 1 (keeper, exactly 500) | 9d05fd57… | 639186 | sub 1, paidThrough 639486 |
| 8 | Cancel 1 (keeper) | be1499ea… | 639189 | terminal; slot freed |
| 9 | Expire 0 (hunter — not the subscriber: the valve is permissionless) | ac84e534… | 639210 | sub 0 expired after grace end 639204 |
| 10 | ClaimAll (hunter) | 4f678c10… | 639213 | 4,500 ugnot to the provider; realm at exactly 0 |
| 11 | RetirePlan 1 (hunter) | 5292728a… | 639250 | plan 1 retired |
Proven live: exact-envelope custody; paidThrough arithmetic never drifting from the Subscribe-time schedule (639144 → 639159 → 639174 → 639189, each +15 from paidThrough, twice while lapsed-in-grace); the early bound refusing the accidental duplicate with its precomputed reopen height; the grace bound closing at exactly the documented height; the Expire/renewable partition (refusal quoted "grace runs through height 639158" = paidThrough + period − 1); audit-Y1 behavior on chain — after Cancel, EntitledFor still true, ActiveSubID empty, Renew refused as terminal; conservation H == U + F at every checkpoint (1000 = 1000 + 0 after the first payment; 0 = 0 + 0 after the final claim) with 4,500 ugnot round-tripped keeper → realm → provider and the realm drained to exactly zero; the live Render page showing both plans.
15 adversarial rejections, all through the same VM path (simulate): wrong amount ("send exactly the plan price"), duplicate active subscribe ("renew it instead"), wrong-payer renew, wrong-caller cancel, premature expire (boundary height quoted), non-recipient WithdrawFees, non-admin SetFeeBps, stray-send on Cancel, period below bounds, too-early renew ("renewable from height 639174"), grace-over renew, renew of a cancelled subscription, WithdrawFees on an empty pot, sweep with no surplus, and subscribe to a retired plan.
NOT verified live — stated, with the committed tests that do cover it
- The fee path's success legs (SetFeeBps → plan snapshot → accrual → WithdrawFees paying out). The harness permission classifier denied the live
SetFeeBps(250)call as an unrequested persistent fee on shared state; per CLAUDE.md §5 this is reported, not routed around, and the whole live run therefore executed at the deployed default of 0 bps. Covered byTestFeeConsentCeiling,TestClaimsAndFees,TestFeeBounds(snapshot immutability, consent ceiling, accrual split, recipient-only withdrawal). The refusal legs (non-admin, non-recipient, empty pot, out-of-range would-be values) WERE exercised live. - SweepDenom's success leg — needs an out-of-band bank send to the realm address, and no MCP tool sends plain coins to an arbitrary address (
gno_key_sendis key-name-only; same limitation recorded for bounty_panel §12). Covered byTestSweepReserve, including the reserve-protection assertion. The no-surplus refusal was live. - Subscribe collapsing an expired incumbent — the live run used the Expire valve instead. Covered by
TestSubscribeCollapsesExpiredIncumbent. - Admin two-step handover — deliberately not exercised so the deployer remains admin/feeRecipient. Covered by
TestAdminHandoverincluding wrong-acceptor and former-admin lockout. - Quota ceilings (20 plans/provider, 100 subs/subscriber) — gas-priced loops, not exercised live. Covered by
TestPlanQuota,TestSubscriberQuota. - Realm-to-realm callers — no realm caller exists to drive; the header documents the assertNoSend origin-envelope caveat verbatim from the siblings.
Invariants and limitations carried forward
The monetary invariant is Held == UsersTotal + FeesAccrued (no escrow term); SweepDenom reserves Liabilities() so balances and the pot are structurally unsweepable. The obligation model is deliberately one-sided: the realm owes entitlement, subscribers owe nothing — there is no pull payment, so a lapse is a lapse, never a debt. latestByKey entries are permanent tombstone-class state (one per (plan, subscriber) pair ever, overwritten, self-paid) — graded acceptable by the audit for the same reasons as the sibling realms' tombstones. Deployed bytes are immutable; the realm remains frozen after this record per CLAUDE.md §6.
Correction, 2026-09-23 UTC: three claims in §17 above, amended not rewritten
Found by the repo-wide audit the day after the record was written. No transaction, byte, height or money figure below changes anything about the deployed realm; these correct the record's own prose.
- "12 application transactions" is wrong: the correct count is 11, and the table above already lists all 11 with hashes and heights. The twelfth in the author's tally was the CreatePlan attempt that died at the pre-broadcast simulate stage on
lockStorageDeposit(the funding note above) — a refusal that never reached the chain and therefore is not a transaction.catalog/applications.mdsaid "12" by propagation and now says 11. - "gnomod.toml 90 B" is wrong: the committed file is 89 bytes (
git show 9e965bb:pearl/r/subscriptions/gnomod.toml | wc -c→ 89; the 90 was carried over from §15's service_market gnomod, whose module path is one byte longer). The custody claim is unaffected — the byte-match was performed on the actual files, not on this figure — and the 1.815× overhead survives either denominator. - "the renewable and expirable height sets partition exactly" is overstated as written. The partition holds over
[paidThrough − periodBlocks, ∞); below that — the too-early zone after a prepay renewal — a height is in neither set, and §17's own live probe exhibits it (heights 639170–639173, refusal "renewable from height 639174"). The realm'sRenewableFromdoc states the scoped form correctly; the unscoped sentence appears in the frozen realm header (report-only, bytes immutable) and stood uncorrected here and in the realm's README, which is now qualified.