Pull issue - #519
Open
ARBOR-L wants to merge 144 commits into
Open
Conversation
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.
…instead of market ID (20 bytes)
…_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.)
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.