Skip to content

Pull issue - #519

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

Pull issue#519
ARBOR-L wants to merge 144 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 11 commits August 15, 2026 10:26
Market creation is an authority action; the button now lives only in
the Authority (/admin) section. Public overview is read-only.
- useAssetBalance hook exported from AssetRows (plugin AssetBalance)
- Deposit USDC: 'Wallet balance: 10000 USDC' above the Amount field
- Deposit ETH collateral: 'Wallet balance: 15 ETH' above Current collateral
- Only rendered when connected; integer units (faucet convention)
Faucet ledger stores whole units (BTC 5, ETH 15, USDC 10000) but forms
parsed input at 9-dec and displays divided by 1e9, so a 1 USDC deposit
requested 1e9 units -> 'insufficient funds'. Sweep:

- market forms (deposit/withdraw/borrow/repay/liquidate): parse at 0
  decimals, render positions/shares/debt as whole units
- NATIVE_DECIMALS 9 -> 6 (native ARBOR is uCNPY, 6-dec) — send form
  now parses and previews consistently at 6
- NUSD stays 6-dec; prices stay 1e8; $ value math uses whole shares
- copy: '9-decimal convention' hints replaced with whole-unit wording

A 1 USDC deposit now debits exactly 1 from the 10,000 faucet balance.
DeliverMessageDeposit minted lender shares and moved Account.Amount
(custody ledger) without ever debiting AssetBalance{37} (the
faucet-credited ledger CheckMessageDeposit/faucet.go operate on).
AssetBalance had a credit-only write path (faucet.go), no debit path
existed anywhere -- allowing unlimited share minting against a fixed
faucet balance.

- custody_arith.go: add debitAssetBalanceAmount, mirrors
  debitAccountAmount's compare-before-subtract pattern.
- deposit.go: debit depositor's AssetBalance by msg.Amount, keyed on
  market.DebtAssetId. Fails with ErrInsufficientFunds on shortfall --
  new DeliverTx-level admission check (CheckMessageDeposit remains
  stateless and cannot check this).
- withdraw.go: mirror-image fix found during audit -- credit
  AssetBalance back by actualWithdrawn on redemption. Without this,
  AssetBalance would only ever decrease post-deposit-fix.

Audited all other value-moving handlers (borrow, repay, collateral,
mint_nusd, burn_nusd, liquidate_position, liquidate_nasm_vault) --
confirmed none of them touch AssetBalance, so this was isolated to
the deposit/withdraw pair.

Verified live on devnet (market faucet-fix-test-01, debtAssetId
USDC): deposit 1+2 USDC correctly debits 10000->9999->9997,
malformed 1e9 deposit rejected with insufficient funds (code 9,
no state mutation), withdraw of all 3 shares credits back to
10000 (full round trip), lender position correctly deleted at
zero shares.

Known follow-up (not blocking): pre-existing devnet test markets
use lowercase debtAssetId (usdc) while faucet.go credits uppercase
(USDC) -- no casing normalization exists in create_market.go or at
the AssetBalance key boundary. Will cause ErrInsufficientFunds on
deposits against those markets until normalized.
- rpc.ts: queryFailedTxsByAddress(address) fetches failed txs from node
- TxExplorer: unified feed merging successful (queryTxsBySender) +
  failed txs, sorted by height desc, status pills, expandable details
- /tx page: session tracker pinned at top, historical feed below
- Connected wallet: shows all activity from shared node
- Not connected: connect prompt
- Auto-refresh every 15s
…leftovers

- queryExplorerTxsBySender: POST {address,pageNumber,perPage} (the exact
  shape verified against the node), maps messageType + transaction.msg
- TxExplorer renders the real activity feed now
- regex sweep of any remaining formatAmount(x, 9) -> whole units
  (fixes 'Collateral 0.000000001' on market page)
Script was submitting lowercase asset IDs (eth, usdc), but markets are
created with uppercase IDs verbatim (create_market.go does not
normalize casing) -- every submission would have failed with
ErrAssetNotInMarket against any uppercase-registered market, which is
all of them on this deployment. Also had no BTC support and no
scheduling (single-shot only), so submitted prices would go stale
between manual runs.

- Asset IDs now uppercase (BTC/ETH/USDC), matching on-chain casing.
- Added BTC via CoinGecko's bitcoin price feed.
- Replaced --market-id (which only served submission-time
  authorization, since PriceRecord is keyed by (asset_id, submitter)
  not by market -- see update_price.go) with ASSET_MARKET_MAP, mapping
  each asset to a market that actually lists it as collateral or debt.
- Added --loop/--interval-seconds for continuous polling (default 30s),
  well under Tier 1's 30-block staleness window.

Verified live against the grad node: registered BTC/ETH/USDC asset
tiers via set_asset_tier, ran the fixed script, confirmed PriceRecords
land correctly-cased and ResolvePrice succeeds -- frontend now shows
live USD valuations instead of 'pending oracle quorum' on usdc-eth-01
and usdc-btc-01.
- suppliedUsd: Number(m.totalSupplied) * (price / 1e8)
- borrowedUsd: Number(m.totalBorrowed) * (price / 1e8)
- 129 USDC @ $1.00 now shows $129.00 instead of $0
- Old: 'Borrow against BTC' (ambiguous, reads like 'Borrow BTC')
- New: 'Borrow USDC' (what you borrow) + subtitle clarifies
  'post BTC as collateral, draw or repay USDC'
- Removes the confusion: title = borrowed asset, subtitle = collateral
…lth badges, contextualized stats

- Split market card into intent-first lead tiles (lend earn / borrow cost APRs)
- Collapse protocol internals (S rate, B index, loss factor, layer4) into <details>
- Add (?) tooltips to all jargon terms with one-line explainers
- Replace raw health factor with colored badge (Safe/Caution/At risk) + progress bar
- Add 'you are here' label to rate curve showing current utilization and APR
- Contextualize max borrow with tier LTV percentage
- Fix collateral display (whole units, not 9-dec)
- Fix home card market labels (collateral/debt instead of tier name)

Implements full readability spec for first-time tester onboarding.
ARBOR-L added 18 commits August 16, 2026 14:40
BorrowForm:
- availableLiquidity = totalSupplied - totalBorrowed
- remainingBorrow = min(collateralCap, availableLiquidity)
- Max borrow display shows binding constraint (collateral vs liquidity)
- Prevents 'insufficient funds' when borrow exceeds market liquidity

Market detail page:
- Re-applied intent-first tiles (lend-earn / borrow-cost APRs)
- Collapsed protocol internals into <details>
- Tooltips on all jargon terms
- Health factor badge with risk bar
- Rate curve 'you are here' label
- What would I earn if I lend? (supply APR)
- What would it cost me to borrow? (borrow APR)
- Collapsed protocol internals into <details>
- Tooltips on all jargon terms
- withdrawableNow = min(tokensForShares, totalSupplied - totalBorrowed)
- Hint shows 'Withdrawable now: 75 USDC (limited by market liquidity)'
- Client-side error before signing: 'Exceeds available market liquidity'
- Prevents on-chain failure when liquidity < share value
- MarketRow now renders all 11 columns (market id, 5 flags, 3 pools,
  loss factor, coverage ratio) instead of just 2 cells
- Stat cards use formatAmount(x, 0) for whole units instead of 9-dec
- Footnote updated: 'whole units' instead of '9-decimal units'
- TVL 1229, collateral 2, etc. instead of 0.000001229
- Per-market debt-asset price via useAssetPrice, summed in parent
- Total TVL card: $1,229.00 (1229 native)
- Total R_fund card: $0 (0 native)
- Falls back to $0 when oracle price unavailable
- Each market's collateral pool valued at its own collateralAssetId price
- Layer 1 card: USD total (e.g. $1 BTC + $3 ETH = $4) with breakdown
  '1 BTC · 1 ETH' as subline
- Value now moves with BTC/ETH oracle updates instead of summing raw units
- debtUsd/collUsd: remove /1e9 (whole-unit quantities x oracle price)
- hf === 0n (no debt, Inf) is NOT liquidatable: distance Safe,
  close factor dash, no Liquidate button
- Sublines show whole units: 1026 USDC, 1 BTC
… primer

- Forms refetch borrower position on tx confirmed, plus a 2.5s re-check
  to cover node indexing lag (fixes 'debt still shows 1 after repay')
- Phase token derived from txStore TxPhase union, not guessed
- LiveDot on Debt: pulsing dot + 'accrues every block' tooltip
- FirstTimeExplainer: one-time plain-language primer on first connect
Withdraw collateral:
- 'Withdrawable now: X BTC' — collateral minus the minimum your debt
  requires at current oracle prices, with LiveDot (price/debt live)
- Fractional input shows ONE clear error: 'Whole units only — BTC
  amounts on this chain are whole numbers' (no contradictory pair)
- Typing clears stale localError

Repay:
- LiveDot on Current debt (accrues every block)
- Max button fills the exact current debt
- Explainer: exact repay can leave 1-2 dust units because interest
  accrues between reading and execution; repay max again to close
Withdraw collateral:
- 'Withdrawable now: X BTC' = collateral minus debt-required minimum
  at current oracle prices, with LiveDot
- Fractional input shows ONE clear whole-units error
- Typing clears stale localError

Repay:
- LiveDot on Current debt; Max button fills exact current debt
- Explainer for dust: interest accrues between read and execution
- RepayForm was recomputing currentDebt locally via scaledDebt() instead
  of using position.currentDebt from the API
- This caused 'Repay amount exceeds current debt' when user typed the
  displayed live value (divergence between local math and API value)
- Now uses position.currentDebt directly — same value shown and validated
- Applied same fix to BorrowForm, WithdrawCollateralForm, LiquidateForm
CheckTierConcentrationCap's guard checked newGrandTotal.Sign() == 0
(post-mint grand total is exactly zero), which can only be true for a
zero-amount mint -- already rejected earlier by CheckMessageMintNusd's
own ErrInvalidAmount check, so this guard never actually fired.

Real gap: on the system's first-ever mint (existingTotalSupply == 0),
newTierTotal always equals newGrandTotal -- the mint is simultaneously
the only backing for its own tier AND the only supply that would
exist -- making it mathematically exactly 100% of a brand-new total,
which always exceeds the 70% cap regardless of mint size. This
permanently blocked every first NUSD mint on any fresh deployment.

Fix: exempt only when existingTotalSupply == 0 (system-wide, not
per-tier), and only for that one first mint -- a mint into an
already-nonzero total (e.g. Tier N-1's first mint when Tier N-0
already carries backing) is a genuine concentration event and is
still checked normally.

Verified other mint_nusd guards unaffected: asset-value/risk
differentiation between collateral types is handled correctly and
separately via ResolveNasmTier's per-asset LTV table and the
HF_n = (V_nc * LTV_liq) / D_nusd health-factor check (oracle-priced,
Section 2.2), not by this concentration cap -- confirmed no
unit/value-mismatch bug exists there.
No minimum mint size exists anywhere in mint_nusd.go, and there was no
way to preview the max NUSD mintable for a given collateral quantity
before submitting a real transaction -- the max-mintable math
(V_nc * LTV_liq / D_nusd, NASM Spec Section 2.2) only ever existed
inline inside DeliverMessageMintNusd, surfaced solely as part of a
rejection error message after a failed mint.

- nasm_tier.go: extracted the calculation into CalcMaxMintableNusd, a
  standalone read-only function (mirrors this codebase's established
  discipline against duplicated logic, e.g. applyTierBackingDelta's
  own doc comment on why a single implementation is mandatory).
- mint_nusd.go: DeliverMessageMintNusd now calls CalcMaxMintableNusd
  instead of inlining the same big.Int arithmetic a second time.
- rpc.go: new GET /v1/query/maxmintablenusd?collateralAssetId=X&
  collateralQuantity=N route, letting the frontend show a live 'max
  mintable' figure before a user submits anything -- same UX pattern
  already used for 'max borrow' on lending markets. Constructs a
  minimal *Contract{plugin: p} to call the shared function rather than
  duplicating ResolvePrice's median-of-quorum/staleness logic inline,
  which every other RPC handler in this file does for smaller reads
  but would be a real drift risk for logic this substantial.

This does not add a minimum mint size -- that's a separate, disclosed
open question (no spec reference found for one) worth a product
decision rather than an assumption baked into this fix.
The previous version constructed a bare &Contract{plugin: p} and
called CalcMaxMintableNusd(c, ...) directly, reusing ResolvePrice/
ResolveNasmTier's Contract-based function signatures rather than
duplicating their logic. This crashed the plugin process on every
call to the route -- StateRead (which those functions call internally
via c.plugin.StateRead) routes through sendToPluginSync, which uses
c.fsmId as a live request-tracking key into p.pending/
p.requestContract. A fabricated Contract's fsmId is always the zero
value, so every call through this path reused request ID 0 --
colliding with real in-flight transactions using that same id, or
leaving a response channel that never received what it was waiting
on. Confirmed via HTTP status: the route consistently returned 502
while every other route on the same server stayed healthy (200),
ruling out 'binary not deployed yet' and pointing at a handler-local
crash instead.

QueryState's own doc comment states the fix directly: it 'is NOT tied
to an in-flight tx/block lifecycle and does not require a Contract
context; it allocates its own request id, making it safe to call from
custom RPC handlers.' Every other RPC handler in this file already
follows that guidance (handleQueryPrices, handleQueryNasmTier) by
inlining small amounts of Contract-level logic against p.QueryState
directly rather than reusing Contract-shaped functions.

Fix: handleQueryMaxMintableNusd now inlines ResolvePrice's
median-of-fresh-quorum resolution and GetAssetTier's single-key read
against p.QueryState directly, reusing medianUint64 and
stalenessThresholdTable/nasmTierParamsTable (already package-level)
rather than the Contract-shaped functions that wrap them. No changes
to mint_nusd.go or nasm_tier.go -- CalcMaxMintableNusd is unchanged
and still correctly used by the real DeliverMessageMintNusd
transaction path with a genuine, FSM-issued Contract.
- MintNusdForm: live 'You can mint up to X NUSD' from
  /v1/query/maxmintablenusd, estimate-may-shift note, cap validation,
  not-eligible note
- BorrowForm: LTV% and liquidity-cap framings shown together
- Home cards: R_fund/Loss factor/L4 pending behind 'Protocol internals'
- Rate model tab: interest remainder demoted to footnote
- nusd + send 'Your Vaults' tables divided collateralQuantity and
  escrowed pool by 1e9; contract treats these as whole units
  (confirmed via CalcMaxMintableNusd trace: raw 1 = 1 whole BTC)
- NASM-01 now shows 1 BTC instead of 0.000000001; USDC vault was
  equally wrong (1 whole USDC shown as 0.000001), not BTC-specific
- Matches the 0-decimal convention of every other collateral site
- R_fund after / T_fund after / seized collateral / total supplied
  equiv were divided by 1e9; same native-unit convention as Monitor
  and Liquidation pages which use 0
- Loss-factor line (RAY / 1e18) intentionally untouched
- Previous audit grep missed these due to nested parens in the
  pattern; re-run with paren-aware grep confirms clean
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