Skip to content

Pull issue - #519

Open
ARBOR-L wants to merge 193 commits into
canopy-network:mainfrom
ARBOR-L:main
Open

Pull issue#519
ARBOR-L wants to merge 193 commits into
canopy-network:mainfrom
ARBOR-L:main

Conversation

@ARBOR-L

@ARBOR-L ARBOR-L commented Aug 13, 2026

Copy link
Copy Markdown

No description provided.

root and others added 30 commits July 9, 2026 21:59
Closes the gap where total_supplied and total_shares_outstanding
were clamped to zero silently, with no TotalSuppliedDustClampEvent
or TotalSharesOutstandingDustClampEvent ever fired. Both events now
capture the pre-clamp value before zeroing, per ARCM v3.11.1
Section III.6 / AYIS Section 4.4.

withdraw.go was untracked prior to this commit.
Commits the accumulated Arbor lending protocol work that had been
building up uncommitted on-device: core plugin scaffold, market/
lender/borrower state accessors, deposit/withdraw/create_market/
update_price/set_asset_tier handlers, interest accrual (AYIS),
uint128 encoding helpers, compound interest math, arbor.proto/
arbor_events.proto/arbor_state.proto and generated code, asset
tier logic, and the submit_tx.go / rpc test-harness scripts.

Also includes small, targeted changes to core Canopy files
(plugin.go, go.mod, tx.proto, account.proto, event.proto,
plugin.proto) required for Arbor's custom transaction/event types
and the currentHeight BeginBlock->DeliverTx tracking fix.

This is a checkpoint commit, not a clean history — prior to this,
substantial work existed only on-device with no version control.
Going forward, changes should be committed incrementally per
logical unit of work rather than accumulated.
…_fund RPC query route

Core custody architecture live on-chain: deposit/withdraw/borrow/repay/
liquidate_position move real Account/Pool balances via escrow pools
(pool_id.go, custody_arith.go). Liquidation (liquidate_position.go) wired
to Layer 2 bad-debt draw-down (bad_debt_layer2.go, Layer2DrawDown) per
ARCM Section 9.2 -- all-or-nothing gate against R_fund, covered==false
branch confirmed live on-chain; covered==true branch not yet exercised.

Adds /v1/query/reservefund RPC route (rpc.go) for direct R_fund
visibility, mirroring handleQueryPool's pattern.

Known gaps, disclosed: interest_accrual.go's Insolvent-branch R_fund
routing is a TODO; C4's WillExhaustThisBlock lookahead (ARCM v3.11.1)
is not yet wired, pending Layer 4 (SumLenderBalancesInMarket, {28}
queue) which does not exist yet. See ARBOR_HANDOFF_LAYER2.md.
Root cause (confirmed live on devnet, liq-test-01): applyDebtDelta's
decrement branch (repay/liquidation) clamps market.TotalBorrowed to
zero whenever decrease >= TotalBorrowed, per ARCM v3.10 Section
19.2.1a's spec'd compare-before-subtract logic. This is correct,
intentional behavior -- but it was previously silent: no event,
no log, nothing to distinguish a real drift from a market with
genuinely zero debt. The original finding: a liquidation left
market.TotalBorrowed == 0 while the borrower position's own
debtPrincipal still read 3, no way to tell why after the fact.

Fix mirrors ARCM v3.11.1 Section III.6 / H4's dust-clamp pattern for
total_supplied: applyDebtDelta now returns a named clampedFrom value
carrying the pre-clamp TotalBorrowed whenever the clamp fires, and all
three callers (borrow.go discards it, repay.go and liquidate_position.go
build and emit EventTotalBorrowedDustClamp) surface it as a proper
chain event.

New: EventTotalBorrowedDustClamp{ market_id, source, decrease_amount,
pre_clamp_value }, registered in contract.go's EventTypeUrls.

Verified live: borrowed 10 on liq-test-01 (after refreshing stale eth/
usdc oracle prices and depositing collateral), repaid 14 against a
combined debtPrincipal of 14, confirmed via /v1/query/events-by-height
at height 6852 -- event fired with source="repay", decrease_amount=14,
pre_clamp_value=10, reference=<repay tx hash>. Decoded raw protobuf
bytes to confirm field values independent of the RPC's generic (and
mislabeled) event JSON view.
Merging origin/main (upstream canopy-network sync, go 1.24->1.26
bump) produced a go.mod with two 'go' directives (1.24.0 and 1.26),
which go tooling rejects outright (repeated go statement). The
active toolchain in this environment is already go1.26.0; set
go.mod to match rather than pin back to the now-superseded 1.25.11
toolchain this plugin was previously built against.
…anch

Previously, AccrueInterest's Insolvent-status branch (Step 8, AYIS
Section 7 J1/K1) computed interestEarned but discarded it entirely --
the TODO comment noted R_fund accessors didn't exist yet, but
GetReserveFund/SetReserveFundTry were already implemented and in use
by the non-Insolvent path (Step 10). This was a real value leak: for
any Insolvent market, every block's interest_earned would vanish
rather than routing to R_fund as ARCM Section 9.3(1) requires.

Currently unreachable in practice -- no code path can set
market.Status = MarketStatus_INSOLVENT yet (Layer 4 / loss-factor
socialization is not implemented) -- confirmed via full-codebase grep
before and after this change. Fix is structurally verified (compiles
clean, mirrors the exact GetReserveFund/SetReserveFundTry/overflow-
freeze pattern already verified working at Step 10) but NOT live-
verified on a real Insolvent market, since that state is not yet
reachable on any chain. Live verification is pending Layer 4.
The endpoint previously returned only the raw BorrowerPosition proto
via protojson.Marshal, whose debtPrincipal field is the stored
principal as of the position's last write -- not the borrower's
current owed debt. ScaledDebt()'s own doc comment (AYIS Section 6,
ARCM Section 2.2) explicitly warns pos.DebtPrincipal alone must never
be treated as current debt; this endpoint was doing exactly that
implicitly, by exposing it under a field name a caller would
reasonably assume is authoritative.

Found via a live regression check on borrow-test-01: after a partial
repay, debtPrincipal read 4001 while totalBorrowed (the aggregate)
correctly read 4000 -- a 1-unit gap explained by interest accrued
between borrow and repay via B_index growth, not a drift bug. Traced
and confirmed via ScaledDebt()'s ceiling-division formula before
concluding this.

Now batches a second single-key read for the market's B_index
alongside the existing position read (matching the QueryId-per-result
pattern used in deposit.go/contract.go/price_resolve.go -- QueryId
lives on PluginReadResult, not on individual PluginStateEntry
entries), computes ScaledDebt(position, bIndexNow), and adds it as an
additive currentDebt field. debtPrincipal and all other fields are
unchanged for backward compatibility. If B_index is missing,
currentDebt is simply omitted rather than failing the request.

Live-verified: real query against borrow-test-01 after rebuild/
restart returned currentDebt: 4002 (one unit above debtPrincipal's
4001, consistent with further accrual since the repay), with all
original fields intact.
First piece of Layer 4 (lender socialization), which per the July 2026
audit was entirely unimplemented -- zero code, not partial. This is the
foundational O(1) read every remaining Layer 4 piece depends on:
ApplyLossFactor() uses it as the exhaustion-check denominator, and
WillExhaustThisBlock() (ARCM v3.11.1 Section 9.3b Rule 3 -- the C4 fix
from the re-audit, already fully specified but blocked on this and the
{28} queue existing) will use the identical comparison one block earlier
as a lookahead.

Computes sum_i(balance_i) == total_shares_outstanding * s_rate *
loss_factor / RAY^2 exactly, per AYIS Section 5.4.2's own algebraic
argument -- no per-position iteration. Carries the same BitLen()
cast-safety guard (J2 precedent) MintShares()/RedeemShares() already
apply at the identical big.Int -> uint64 boundary shape, reusing
ErrShareOverflow rather than inventing a new error type for the same
underlying failure.

Compiles clean, both binaries rebuilt. NOT live-verified: nothing calls
this function yet -- it has zero behavioral effect on any running
transaction until ApplyLossFactor (next piece) wires it in. Structurally
verified only: real accessor signatures (GetSupplyIndex, GetLossFactor)
confirmed via direct inspection before writing, not assumed from the
spec pseudocode's own naming.
Second batch of Layer 4 pieces, building on SumLenderBalancesInMarket:

- proto: new LossFactorQueueEntry message ({28} record, AYIS Section
  12.4). Per-market, not append-only -- a market has at most one
  outstanding entry, matching K3's idempotency guard (a second enqueue
  overwrites rather than accumulating a second bad-debt figure).
  KeyForLossFactorQueue() changed from a bare prefix to per-market,
  matching every other {16}-{28} key helper in this file; confirmed
  zero existing callers before changing its signature.

- loss_factor_queue.go: PeekLossFactorQueue (read-only lookahead,
  shared by ProcessLossFactorQueue's future drain step and
  WillExhaustThisBlock's C4 lookahead), EnqueueLossFactorApplication,
  DequeueLossFactorApplication.

- market_insolvency.go: GetMarketStatus, SetMarketInsolvent (no
  dependency on index_overflow_halted by construction -- satisfies
  ARCM v3.11.1 Section 9.3b Rule 1 structurally, not via a checked
  guard), SetLossFactor, DecrementLayer4Pending (paired count/total
  decrement per ARCM Section 9.2b).

Real mid-implementation correction: initially called EncodeUint128 as
a single-return function per the spec doc's pseudocode ('revert with
error' framing). Built failed real compilation -- this codebase's
actual EncodeUint128 returns ([]byte, *PluginError), a normal Go
error value rather than an implicit panic-and-revert. Fixed at all
three call sites rather than assumed correct from the spec alone.

Also fixed: three heredoc-written files (lender_balances.go,
loss_factor_queue.go, market_insolvency.go) had lost tab indentation
somewhere in the write path and failed gofmt -l. Reformatted with
gofmt -w and reverified clean before this commit -- lender_balances.go
was already pushed in the prior commit with this defect; this commit
corrects it.

DecrementLayer4Pending's underflow branch intentionally does NOT
attempt to emit EventLayer4PendingCountUnderflow (the proto message
exists and is registered, but every existing event-emission call site
in this codebase is DeliverTx-context with a local events slice this
BeginBlock-context function has no access to -- a real, documented,
pre-existing gap, not invented here).

All new code compiles clean (both binaries), gofmt-clean. NOT live-
verified: nothing calls any of these functions yet -- ApplyLossFactor
(next piece) is what wires SumLenderBalancesInMarket, SetLossFactor,
SetMarketInsolvent, and DecrementLayer4Pending together for the first
time.
…d self-liquidation guard

market_insolvency.go: SetMarketInsolvent and DecrementLayer4Pending now
take *Market in place instead of doing their own GetMarket/SaveMarket
round-trips. Two independent read-mutate-save cycles within one tx's
call graph race last-write-wins on any field, not just Status -- this
was found via Status silently reverting to ACTIVE after a Layer 4
exhaustion despite loss_factor correctly persisting at 0.

apply_loss_factor.go: ApplyLossFactor now takes *market directly and
reads market.Status off the caller's struct instead of a third
independent GetMarketStatus() call. Corrected a stale header comment
claiming no caller invokes this function (liquidate_position.go does).

liquidate_position.go: call site passes its own already-in-scope
market struct through; the existing end-of-function SaveMarket(market)
now correctly captures every mutation since there is only one copy of
market in play for the whole function. Also adds a self-liquidation
guard (ErrSelfLiquidation, code 238) blocking
msg.Liquidator == msg.BorrowerAddress.

rpc.go: adds /v1/query/lossfactor route.

Verified live against devnet: layer4-test-03 and layer4-test-04 both
show status=INSOLVENT post-liquidation with the fix in place; a
subsequent borrow against test-03 correctly rejects, and a second
liquidation against the same market hits the K3 idempotency path
cleanly. Self-liquidation guard verified both for rejection and for a
genuine second-address liquidator still succeeding.
…eads

Adds handleQueryAllMarkets ({16}), handleQueryPrices ({19} by asset), and
handleQueryAllBorrowerPositions ({17}, with server-side currentDebt via the
market B_index) to the plugin HTTP server, mirroring the existing BeginBlock
and price_resolve range walks. These let the frontend auto-discover every
market, drive the oracle freshness monitor, and render a global liquidation
view without a hand-pinned id list. Also adds plugin/go/.gitignore for the
built binary and oracle-heartbeat artifacts.
…reader fixes

- adminGetKey reads the PascalCase PublicKey/PrivateKey the admin RPC actually
  returns (the Go struct has no json tags, so the old camelCase read was empty
  and browser connect failed with "No public key returned from admin RPC").
- Lender/borrower position readers strip a leading 0x from the address (the
  plugin hex.DecodeString wants bare hex; 0x => 400) and decode borrowIndexAtOpen
  from its base64 uint128 form instead of BigInt()-ing the base64 string (which
  threw and nulled the whole borrower read, emptying the portfolio).
- Portfolio section: per-position health-factor pills (green/amber/red) plus an
  approaching-liquidation warning banner, from live positions x oracle prices x
  the on-chain tier LTV (computeHealthFactorScaled / TIER_PARAMS).

All values read live from the ARBOR plugin; no mock data. Plugin RPC routes
(all-markets / prices / all-borrower-positions) were already shipped in the
prior commit; this is frontend-only.
…y/liquidate/withdraw_collateral

All four custody-touching DeliverTx handlers were splitting their state
mutations across 2-4 independent StateWrite calls instead of one. Per
the Canopy builder docs' own canonical pattern (batch-read, batch-write
-- operations in ONE StateWrite call are atomic; there is no
cross-call transactional guarantee), a failure partway through any of
these handlers could leave real custody already moved while dependent
records (market.TotalBorrowed, BorrowerPosition, R_fund) never
reflected it -- funds-out-with-no-debt-recorded and similar
inconsistent states, on the exact code paths that move real value.

liquidate_position.go: SaveMarket's own internal StateWrite collapsed
into the existing liquidator/pool/position write.

borrow.go: custody write and the market/position write (previously
two separate StateWrite calls) collapsed into one.

repay.go: up to four independent writes (custody, R_fund routing,
SaveMarket, position) collapsed into one.

collateral.go (withdraw_collateral): custody write and position write
collapsed into one. Also corrects a stale header comment that claimed
this handler was bookkeeping-only with no Account.Amount fund transfer
occurring -- inaccurate relative to the real custody code beneath it.

No business logic changed in any of the four -- every existing
condition, guard, and error path is preserved exactly; only the
commit point moved, from N writes to 1.

Verified live against devnet for all four:
- liquidate_position: real two-address liquidation (borrower vs.
  independent liquidator) on a fresh Tier-1 position pushed
  liquidatable via oracle price update. Full seizure (Tier 3 close
  factor) confirmed correct across liquidator account, both pools,
  and position deletion.
- borrow: fresh market, deposit + collateral + borrow sequence:
  account credit, position debt, market.TotalBorrowed, and supply
  pool all confirmed to move together.
- repay: partial repay against the borrow above: account debit,
  position debt reduction (position correctly NOT deleted, collateral
  remains), market.TotalBorrowed decrement, and supply pool credit
  all confirmed.
- withdraw_collateral: partial withdrawal against the same position:
  account credit, position collateral reduction, and collateral pool
  debit all confirmed.
DeliverTx's switch statement had exactly one case, MessageSend --
every Arbor-specific message type (MessageCreateMarket, MessageDeposit,
MessageWithdraw, MessageBorrow, MessageRepay, MessageLiquidatePosition,
MessageDepositCollateral, MessageWithdrawCollateral, MessageUpdatePrice,
MessagePauseMarket, MessageResumeMarket, MessageDeprecateMarket,
MessageUpdateMarketParams, MessageSetAssetTier) fell to default and
was rejected with ErrInvalidMessageCast(). No Arbor lending operation
could execute on-chain against a fresh build of this source.

Root cause: commit ae03baf ("Merge branch 'main' into main") merged
upstream Canopy's generic send-only plugin template over this file's
DeliverTx switch, silently discarding the Arbor-specific routing, with
no merge conflict. CheckTx's own switch (unaffected by the merge)
continued routing all 15 types correctly, so transactions were still
admitted to the mempool -- but DeliverTx rejected every one of them at
the point business logic would actually run. ContractConfig's
SupportedTransactions/TransactionTypeUrls registration was NOT
affected by this merge and did not need restoring; only the DeliverTx
switch itself was reverted.

This regression was not caught during tonight's earlier custody-
atomicity fixes and live verification (liquidate_position.go,
borrow.go, repay.go, withdraw_collateral) because the go-plugin binary
running throughout those tests had been built earlier, from a source
state that predated ae03baf reaching this checkout -- confirmed via
objdump disassembly of DeliverTx showing full routing in that binary
despite the committed source already being broken. Every one of
tonight's earlier custody fixes is independently still correct and
still verified; this was purely a separate, coincidental routing
regression that a stale-but-working binary had been masking.

Found via two independent AI security audits run against this commit
(bf899fa) that flagged the routing gap; the discrepancy between their
static-source finding and this session's own live-transaction
verification was investigated and resolved by directly disassembling
both the pre-fix and post-fix go-plugin binaries.

Restored by mirroring CheckTx's already-correct case list and order
exactly, calling the existing, unmodified DeliverMessage* handlers
(none of which needed any change).

Verified live against devnet post-fix: rebuilt both canopy and
go-plugin binaries, confirmed via objdump that DeliverTx's compiled
code now calls all 14 Arbor DeliverMessage* handlers plus
DeliverMessageSend, restarted the node, and submitted a real
deposit_collateral transaction -- collateralQuantity on the target
position increased by exactly the submitted amount, confirming the
fix is live and correct, not just present in source.
…casts

liquidate_position.go had two unguarded big.Int -> uint64 casts,
identified by an independent AI audit and confirmed live:

- collateralSeized.Uint64() (ARCM Section 8, non-bad-debt path) --
  collateralSeized is computed from oracle prices with no prior bound.
  An extreme debtPrice/collateralPrice ratio from a single oracle
  submitter (MinReporters=1 on devnet) could push it past 64 bits,
  silently wrapping the amount credited to the liquidator and debited
  from the collateral pool.

- badDebtNative.Uint64() (ARCM Section 9.2, Layer 2 bad-debt path) --
  previously called twice (Layer2DrawDown, ApplyLossFactor), both
  unguarded, with an explicit [DISCLOSED] comment acknowledging the
  gap rather than closing it. Same oracle-price-ratio dependency; a
  wraparound here would corrupt both the R_fund debit and the
  loss-factor lender haircut by the same wrong, understated amount.

Both now guarded with BitLen() > 64 checks before the cast, matching
this codebase's existing pattern (deposit.go's sharesBig guard,
withdraw.go's tokensBig guard) -- reject via new error codes 239/240
rather than silently truncate. badDebtNative's second call site now
reuses the single guarded uint64 value instead of re-casting
unguarded a second time.

On the bad-debt path, collateralSeized is reassigned to
pos.CollateralQuantity (already uint64-derived, safe by construction)
before its own guard runs, so the new check is a no-op there --
it only has teeth on the non-bad-debt path where collateralSeized is
freshly computed from oracle prices.

Verified live against devnet: rebuilt both binaries, restarted the
node, ran a real liquidation (price-manipulated position, Tier 3 full
close factor, full collateral seizure) through the newly-guarded code
path -- succeeded exactly as before, confirming the guards don't
interfere with normal-magnitude values and only reject genuinely
out-of-range ones.

MinReporters=1 (the devnet-only oracle quorum override that lowers the
bar for triggering this class of bug) is intentionally left untouched
per this session's own scoping -- restoring it to a real quorum is a
deployment-config decision for when devnet work is complete, not a
code fix.
Visual system (CSS-only, data path untouched):
- globals.css brand layer: brand tokens (teal #2FD6C0 / violet #7C6CF2 / gold
  #F2B84B), ambient aurora field, lit-edge .glass panels, .brand-glyph asset
  marks, .btn-brand gradient buttons, neon .util-track gauges, type ramp.
- life layer: slow masthead gradient shift + drifting aurora (reduced-motion safe).
- refine layer: solid high-contrast display title with a brand-gradient accent
  rule, faint fixed structural grid, card/button micro-feedback, brand focus ring.
- Class swaps site-wide (home/portfolio/monitor/oracle/liquidation/forms): flat
  bg-white/[0.03] -> .glass, indigo/emerald monograms -> .brand-glyph, indigo
  buttons -> .btn-brand, util bars -> .util-track, headings -> .display-title/.section-h.

Brand assets + chrome:
- public/logo-mark.svg (the real ARBOR icon mark, transparent bg) + header brand
  swap replacing the placeholder gradient square.
- Home masthead collapsed to a single "Protocol overview" display line.

Functional fix:
- Portfolio panels no longer deadlock: tables always mount when connected so the
  per-position rows query and report; the empty-state copy moved to an in-table
  fallback row. Lending + borrowing positions (with live HF pills) now render.

All values remain read live from the ARBOR plugin; no mock data.
ScaledDebt() (AYIS Section 6) previously had no BitLen() overflow guard
on its final cast, disclosed as a deliberate v1.11-era carve-out
("no amplification path analogous to MintShares()/RedeemShares()/
SumLenderBalancesInMarket()") -- a design assumption, not a proven
bound, per Arbor Handoff Part 2 item 2.

- Added ErrScaledDebtOverflow (code 241), matching the existing
  ErrCollateralSeizedOverflow/ErrBadDebtNativeOverflow style.
- Changed ScaledDebt() signature from uint64 to (uint64, *PluginError),
  added the same BitLen() > 64 guard pattern used in deposit.go,
  withdraw.go, and liquidate_position.go.
- Updated all 6 call sites: 4 DeliverTx handlers (borrow.go, repay.go,
  collateral.go, liquidate_position.go) now revert on overflow via
  PluginDeliverResponse.Error; 2 RPC query sites (rpc.go) degrade
  gracefully by omitting/falling back to raw debtPrincipal, matching
  the existing missing-bIndexRaw fallback pattern -- no transaction
  to revert in a read-only query context.
- Added scaled_debt_test.go: regression case confirming normal-magnitude
  values are unaffected, and a deliberately constructed overflow case
  (MaxUint64 debtPrincipal, artificial borrowIndexAtOpen=1) confirming
  the guard actually fires with correct arithmetic, not just compiles.

Live-verified: both binaries rebuilt (core + plugin), go build/vet
clean (vet output unrelated, pre-existing Canopy-core-only findings),
node restarted, RPC reads against real chain state (borrow-test-01,
layer4-test-02, layer4-test-04) confirm correct currentDebt values
through the guarded path before and after restart.
…le TODOs

Scaffolding for ARCM Section 9.2's Layer 3 (protocol treasury), the
missing layer in the bad-debt waterfall between Layer 2 (R_fund,
market-isolated) and Layer 4 (lender socialization, loss_factor).
Layer 3 itself is NOT built by this commit -- no draw-down function,
no waterfall wiring, no funding mechanism. This is state-layer
scaffolding only, following the same order used for every prior
Arbor addition (proto/state key, then accessors, before any logic
wires into it).

state_keys.go:
- PrefixTreasury = []byte{40}. NOT {30}, despite {30} being the next
  free integer after {29} (PrefixAssetTier) -- {30}-{39} is reserved
  for future NASM/NUSD coordination (confirmed as a deliberate prior
  decision, not a stale assumption). {40} chosen with deliberate
  headroom above that reservation rather than sitting adjacent to it,
  so NASM can claim {30}-{39} without Treasury being the first thing
  it collides with.
- KeyForTreasury() -- NOT market-keyed, unlike every other key
  builder in this file. T_fund is a single global uint128 balance,
  not per-market, mirroring KeyForGovernanceParams()/
  KeyForBackstopQueue()'s existing zero-argument JoinLenPrefix shape.

state_accessors.go:
- GetTreasury / SetTreasuryTry / SetTreasury, mirroring
  GetReserveFund / SetReserveFundTry / SetReserveFund's exact
  three-function shape and BeginBlock-freeze-vs-DeliverTx-revert
  contract (Principle 14), adapted for a global rather than
  per-market accumulator. No caller exists yet for any of these --
  write-side contracts are added alongside read-side ones rather
  than deferred until a caller needs them, matching this codebase's
  existing SetReserveFund precedent.

bad_debt_layer2.go, interest_accrual.go:
- Comment-only corrections. Both files carried TODO/gap comments
  written before Layer 4 (ApplyLossFactor, EnqueueLossFactorApplication,
  PeekLossFactorQueue, SumLenderBalancesInMarket), repay.go, and
  liquidate_position.go existed. Re-verified directly against the
  real files rather than re-assumed: Layer 4 machinery now exists
  and is wired in (liquidate_position.go calls ApplyLossFactor on a
  Layer 2 miss); Treasury accessors now exist (this commit).
  ProcessLossFactorQueue (BeginBlock drain) and WillExhaustThisBlock
  (C4 lookahead, AYIS v1.11.1 Section 7 Step 8 revised) remain
  genuinely unbuilt -- re-confirmed, not just re-stated. No logic
  changes in either file.

Explicitly NOT done by this commit, confirmed by direct inspection:
- Layer 3 draw-down function (Layer2DrawDown analog against T_fund)
- Waterfall wiring (liquidate_position.go's Layer 2-miss path still
  falls straight through to ApplyLossFactor/Layer 4)
- Funding mechanism (fee skim or otherwise) -- T_fund has no writer
  anywhere in the codebase yet
- WillExhaustThisBlock / C4 fix

Verified: gofmt clean on all 4 files, go build ./... exit 0,
go vet ./contract/... exit 0.
Reverses the single-shared-treasury design from the prior session's
scaffolding (abb783a): a shared T_fund meant a NUSD-side bad-debt
event could drain Layer 3 protection Arbor lenders were counting on,
and vice versa -- a hidden risk coupling between two products that
should be independent. Reopened and reversed the same session it was
introduced, before any caller depended on the shared design.

- state_keys.go: PrefixTreasuryArbor/PrefixTreasuryNASM at {40}/{41}
  respectively, KeyForTreasuryArbor()/KeyForTreasuryNASM(), replacing
  the single PrefixTreasury/KeyForTreasury. {40} kept for Arbor to
  minimize churn (already live).
- state_accessors.go: GetTreasuryArbor/GetTreasuryNASM,
  SetTreasuryArborTry/SetTreasuryNASMTry, SetTreasuryArbor/SetTreasuryNASM
  -- 6 functions replacing the original 3, mirroring
  GetReserveFund/SetReserveFundTry/SetReserveFund's exact
  BeginBlock-freeze-vs-DeliverTx-revert contract (Principle 14) per pool.
- bad_debt_layer3.go: Layer3DrawDown split into Layer3DrawDownArbor and
  Layer3DrawDownNASM -- distinct functions rather than a parameterized
  single function, so a caller cannot mix up pools at the type level.
  Binary-gate contract (identical to Layer2DrawDown) unchanged.
- arbor_events.proto / arbor_events.pb.go: added EventReserveFundDrawDown
  (retroactive fix -- Layer2DrawDown had no event since it went live)
  and EventTreasuryDrawDown, both inserted before the Layer 4 event
  section in ARCM waterfall order. EventTreasuryDrawDown carries a new
  pool field ("arbor" | "nasm") so an observer can distinguish which
  isolated pool fired a draw.

Verified: gofmt clean (files touched this commit only -- pre-existing
repo-wide formatting drift in unrelated files left untouched, per
project convention), go build ./... exit 0, go vet ./... exit 0.

No caller wired yet -- liquidate_position.go's Layer 2-miss fallthrough
still calls ApplyLossFactor/Layer 4 directly, unaware Layer 3 exists.
That wiring is the next unit of work, deliberately not included here.
…data

- RevealObserver now re-scans on every route change (isomorphic layout effect),
  fixing the home page rendering blank after client-side navigation: the mount-only
  observer left navigated-to .reveal sections stuck at opacity:0 under reveal-armed.
- Header: 8-item nav collapses to a hamburger + glass sheet below md; inline on md+.
- layout: richer metadata (title/OG/favicon = logo-mark) + self-hosted Space Grotesk
  (display) / Manrope (body) via next/font; body className carries the font vars.
- Liquidation "all healthy" note now only asserts health for priced positions.

Data path untouched; all values still read live from the ARBOR plugin.
The Tailwind Play CDN (PostCSS is disabled in this project) ignores the opacity
modifier on arbitrary hex colors, so bg-[#070a12]/95 rendered transparent — the
mobile nav sheet showed the page bleeding through, and the sticky header / wallet
popover had the same latent bug (only hidden at scroll-top over dark space).

Replace those three with real-CSS classes in globals.css (always applied, like
.glass): .arbor-surface (frosted header), .arbor-surface-solid (opaque menu),
.arbor-popover (frosted wallet dropdown). A safety-net regex also strips any
other stray bg-[#hex]/NN to solid so no surface can go transparent again.

Data path untouched.
Resolves the 3.2 open question from the treasury-split session
(HANDOFF_LAYER3_SPLIT.md) by having Layer2DrawDown, Layer3DrawDownArbor,
and Layer3DrawDownNASM all return their post-draw balance as a second
value, then uses that to complete the 3.1 restructure:

- liquidate_position.go: Layer 2 miss now falls through to
  Layer3DrawDownArbor before Layer 4 (ApplyLossFactor), completing the
  four-layer waterfall order instead of skipping straight from Layer 2
  to Layer 4. Stale "Layer 3 does not exist" comment corrected.
- Emits EventReserveFundDrawDown on a Layer 2 cover and
  EventTreasuryDrawDown (pool: "arbor") on a Layer 3 cover, using the
  real post-draw balances now returned by the draw-down functions.
- rpc.go: adds /v1/query/treasury?pool=arbor|nasm, the last balance in
  the waterfall with no query surface (R_fund and loss_factor already
  had one). No marketId param -- T_fund is a single global balance per
  pool, matching KeyForTreasuryArbor/KeyForTreasuryNASM's own no-arg
  shape.

Verified: gofmt -l, go build ./..., go vet ./... all clean from the
plugin/go module root (not ~/arbor -- these are separate go.mod trees;
~/arbor's own ./... does not reach plugin/go/contract at all).

Not yet done, left for follow-up:
- No live devnet/RPC verification of this session's changes.
- NASM's own waterfall (Layer3DrawDownNASM has no caller anywhere yet).
- treasury_cut funding mechanism, still unbuilt.
The menu panel itself is now opaque (prior fix), but its backdrop was a
transparent click-catcher, so the bright home page showed through undimmed below
the dropdown and read as "bleeding". Add a CDN-proof .arbor-scrim (near-black +
slight blur, hand-written CSS so the Play CDN opacity bug cannot affect it) on
the menu backdrop so opening the drawer dims/softens the page like a real mobile
overlay. Wallet popover backdrop left unchanged. Data path untouched.
The new logo's symbol (a merkle/index tree drawn as a literal tree/canopy with
gold leaf-nodes) is the strongest mark yet, but its as-authored cream seal is
tuned for light/framed surfaces, not the dark inline header (a cream tile +
forest green would clash with the ink/teal/violet glass UI).

So the one artwork is used in two roles, generated from a single shared
geometry set (the tree is identical in both, just re-skinned):
- public/logo-tree.svg: transparent tree recolored to the brand gradient
  (teal->violet) with our gold leaf-nodes, for the header, plus an ambient
  teal/violet drop-shadow glow (.arbor-mark) so it reads as lit on the ink.
- public/logo-seal.svg: the cream seal as-authored, for favicon / OG /
  apple-touch (self-framed, correct where a transparent mark would vanish).

Header <img> repointed to the tree; metadata icon/OG repointed to the seal.
Data path untouched. (Byte-exact seal override: cp your arbor-logo.svg to
public/logo-seal.svg — same filename, no code change.)
ARBOR-L added 30 commits August 21, 2026 23:29
- Empty/whitespace EVM field → signs 'evm:none' (was signing 'evm:')
- Non-empty EVM → lowercased before signing (was signing raw case)
- Payload also normalized so we send what we signed
- Mirrors quest_xp.go questXPHandleLink normalization byte-for-byte
- Remove 'X connected.' / 'Linked!' status display from IdentityLinkCard
  (redundant — card shows connected state inline)
- Add 'Export CSV' button to Weekly leaderboard card
- CSV includes rank, address, evm_address, xp columns
- EVM column populates when backend includes evmAddress in leaderboard
  response (backend patch needed in quest_xp.go)
- refetchIntervalInBackground: true on price + liquidation queries so
  backgrounded phone tabs keep polling instead of freezing
- staleTime 20s -> 5s: returning tabs refetch instead of painting cache
- default refresh interval 25s -> 15s
- Chain data verified fresh (price 2 blocks old at query time); the
  stale reads were client cache artifacts, not backend staleness
- Add AUTHORITY_ADDRESSES set with e35a3b07e600817905858fca98a0420637b44f63
- Authority users auto-see advanced wallet options (no toggle needed)
- Admin RPC tab visible only to authority addresses
- Authority badge displayed next to wallet address when connected
- Non-authority users: Admin RPC hidden even in advanced mode
- Header chip shows amber ADMIN pill whenever the connected address is
  in AUTHORITY_ADDRESSES — regardless of login method (MetaMask derive,
  paste key, import, device cache, Admin RPC all set the same address)
- Wallet modal shows Admin banner for authority addresses
- effectiveAdvanced = advancedMode || isAuthority: authority access is
  reactive, not mount-time — connecting after modal open still unlocks
- Toggle hidden for authority users (their advanced set is always on);
  non-authority users still never see Admin RPC
- New /admin page: authority status card, Admin RPC key fetch + connect,
  monitoring quick-links (Monitor / Liquidation / Oracle / Governance)
- Non-authority or disconnected visitors get a plain 'Not authorized' card
- Header 'Authority' nav item now renders when EITHER the frontend
  authority set (e35a3b...4f63) OR the on-chain protocol role matches
- Header component now imports useWalletStore to access connected address
  for the nav gate check
- Events page transformed into full Explorer console
- Live network widget: pulsing orb, animated gradient grid, glow-pulse,
  real-time block height + market count + ticking tx counter
- Address dossier: any address (or one-click 'Use my wallet') shows
  asset balances (BTC/ETH/USDC), NUSD balance, borrower/lender positions
  per market, and NASM vault ownership — all read live from the plugin
- Privacy-first: every lookup is a direct browser→node read of public
  state; no search history stored or relayed (visible note in UI)
- Motion polish: staggered fade-up cards, pulse/glow/grid-drift keyframes
  with prefers-reduced-motion respected by existing global rules
- The explorer rewrite accidentally replaced the Events & activity log
  section; restore it from git as components/explorer/ActivityLog.tsx
- Explorer page now stacks: live network widget + address dossier on
  top, full activity log (Activity/Failed/Consensus tabs, paginated tx
  table, waterfall activity) below a divider — nothing removed
- Section title refined to 'Activity log' to sit inside the explorer
- Previous restore used cwd-relative paths in 'git show rev:path', which
  resolves from repo root — every lookup failed, and the wrong-commit
  state (explorer mounted twice) shipped
- ActivityLog.tsx now rebuilt from the last commit containing the real
  activity log: Activity/Failed/Consensus tabs, paginated tx table,
  waterfall section with refresh
- Events page = explorer widgets on top, activity log below, once each
- Network widget's simulated TX ticker replaced with the explored
  address's real transaction total (queryTxsBySender, 15s refresh),
  labeled 'Address TXs' so the figure is honest
- Explorer lookup now drives the activity log too: activeAddress is
  passed as externalAddress and synced into the log's lookup state, so
  dossier + tx table + Failed/Consensus tabs all switch together
- Activity log's own input remains as fallback when nothing is looked up
- Add accountsChanged listener with cleanup, scoped to sessions
  that actually originated from MetaMask (paste/import/admin
  sessions are left untouched)
- Track ETH<->Arbor address origin so a session restored via the
  device-cache path is still recognized as MetaMask-derived and
  stays reactive to account switches
Device-cache silent reconnect previously trusted a cached
MetaMask-derived session without checking whether MetaMask's
active account had changed while the tab was closed. Now it
verifies via eth_accounts before restoring; on mismatch it falls
through to deriving fresh for whatever account is actually
connected, which always requires a real signature rather than
silently trusting stale state.
On mobile browser-extension bridges window.ethereum can be injected
after page hydration instead of before it, unlike desktop Chrome.
Auto-reconnect and the accountsChanged listener both checked
synchronously on mount and gave up permanently if the provider
wasn't there yet -- explaining why a manual tap on Connect worked
(more time had passed) but page load alone didn't. Both now wait
for either an already-present provider or the ethereum#initialized
event, bounded by a timeout.
eth_accounts is spec'd to never prompt, but on this MetaMask/
browser-extension bridge it was returning empty even for an
already-authorized origin -- explaining why only the Connect
button (which uses eth_requestAccounts) worked. Switched the
reconnect check to eth_requestAccounts, which resolves silently
for an already-authorized origin and only prompts when
authorization genuinely isn't granted. Gated behind a local
'has connected via MetaMask before' cache check so first-time
visitors never see an unprompted permission popup.
- Quetta/Rabby inject window.ethereum asynchronously after page load;
  the mount-time silent-reconnect effect checked before injection and
  gave up, so refresh showed 'Connect wallet' until a manual tap
- Poll for window.ethereum every 250ms up to 6s before attempting the
  MetaMask silent path; device-cache path (no extension needed) still
  runs first and is unaffected
- The device-cache path previously verified MetaMask-derived sessions
  against getAlreadyConnectedEthAccount() BEFORE any provider-injection
  wait; on mobile the probe returned null (extension not injected yet /
  wallet locked), read as 'account changed', and abandoned the cache
- Device-cache restore is now unconditional: the cached key is the
  user's own previously-connected key, restored with zero provider
  involvement -- refresh reconnects instantly for every connect method
- MetaMask silent path (with 6s injection wait) remains as fallback for
  returning MM users without a device-cache entry
- MetaMask connect now also writes the device cache (cachePrivateKey),
  closing the gap where only paste/generate/import populated it
- Account-switch safety: users who change MetaMask accounts simply
  disconnect/reconnect manually
…estore

- Manual disconnect no longer resurrects: a wasConnected ref marks
  sessions where isConnected was true; the silent-reconnect effect skips
  its own trigger when the flip came from a user disconnect, so the
  connect modal opens disconnected and manual choice works
- Refresh restore for pre-device-cache MetaMask sessions: step 2 now
  reads the localStorage derived-key cache directly (keyed by eth
  address) with zero provider probing -- the mobile
  getAlreadyConnectedEthAccount() probe (popup/lock-fail) was the reason
  refresh reconnects never worked on Quetta/Rabby
- Behavior matrix: refresh -> instant restore; disconnect -> stays
  disconnected until manual connect; cache clear -> clean first visit
- handleMetaMaskConnect cached the device key via
  useWalletStore.getState().address right after connectFromRawKey; if the
  store hadn't committed the address, the write silently skipped and
  refresh had nothing to restore. Use derived.address (synchronous).
- Silent-restore failures were swallowed by .catch(() => {}); the effect
  now records a step trace (addr/key/eth/mkey/conn/err) rendered as a
  tiny line in the connect modal, so any future mobile-restore failure
  is diagnosable from a screenshot instead of guesswork
- Silent reconnect effect now waits 100ms before attempting
  connectFromRawKey, matching Praxis's 'await window.blsReady' pattern
- Without this, the noble-curves BLS library may not have finished
  initialization at React mount time, causing getPublicKey() to throw
  silently and the .catch(() => {}) to swallow the error
- Manual connect worked because by tap-time the lib was fully loaded;
  this fixes the mount-time race so refresh reconnects work too
- Previous patch had 'disconected' (one n) in the useState generic,
  causing the state declaration to not match the usage sites
- Now declared as useState<'changed' | 'disconnected' | null>(null)
  matching all setWalletDrift calls throughout the component
… sessions

- The wallet-sync patch's step 2 (re-arm mmEthAddress from the origin
  map after device-cache restore) was discarded when the first patch
  run aborted before writing the file; it was never re-applied
- Consequence: restored sessions had mmEthAddress=null, so the drift
  detection effect never armed and Rabby account switches after a
  refresh were invisible -- exactly the gap Praxis doesn't have (it
  sets its equivalent on every load)
- Also render the reconnect trace in the main connect modal (it was
  only in the keystore-unlock branch, so it never showed in testing)
…t fully disconnect from wallet/dApp permissions
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant