Status: ETHGlobal New York 2026. Network: Hedera Testnet (chain 296). Vault: a Solidity
contract on Hedera EVM (HSCS) creating/holding HTS tokens via @hiero-ledger/hiero-contracts.
Scripts + frontend: TypeScript (viem + React). Settlement: native HBAR. Target track:
Hedera β Tokenization.
This spec is the build blueprint, implemented as written and live on Hedera Testnet (Sourcify-verified; canonical addresses in
deployments/testnet.json). Nothing is mocked except the DePIN reward cashflow (Β§9), modeled on-chain byMockRewardSource.
| # | Decision | Choice |
|---|---|---|
| D1 | Settlement asset | Native HBAR (tinybar, 8dp). USDC = roadmap. |
| D2 | Investor access | Admin allowlist β adminGrantKyc(poolId, investor); no auto-KYC on deposit. |
| D3 | Reward routing (prod target) | Device-NFT escrow (Helium recipient/destination model), keeper drip via HIP-1215; bridge relayer = the one residual trust. |
| D4 | Secondary market | In scope β SaucerSwap V1 share/WHBAR pair, KYC-deadlock resolved (Β§10). |
| D5 | Redemptions | Idle + queue + secondary β instant up to liquid cash, remainder FIFO-queued, SaucerSwap always available. |
| D6 | Risk class / category | On-chain enum fields on Pool + Claim (admin assigns class). |
| D7 | Deal proposal workflow | Implemented on-chain: propose β review β assign-class β finance. |
| D8 | Authorized settler | Per-claim allowlist (operator + protocol relayer + owner) β reconciles operator-vs-relayer. |
| D9 | Admin custody | Ownable2Step, owner = Safe multisig in prod; markDefault + financeClaim timelocked; operator whitelist. |
| D10 | Compliance levers | Implemented β freeze/unfreeze + pause/unpause are real, not dead keys. |
| D11 | Share fee | NONE (removed) β on Hedera a fractional fee is assessed on every non-collector transfer and reverts INVALID_ACCOUNT_ID, breaking redeem (operatorβvault) and AMM/secondary (operatorβpair). A tradeable pool-share ships as a plain fungible token; a compliant fee would need permissioned transfers (roadmap). |
| D12 | NFT metadata | 32-byte keccak hash of canonical deal JSON (β€100B, no pinning infra); full deal fields go in events. |
| D13 | Internal accounting width | uint256 internally; downcast to int64 only at HTS boundaries; require(msg.value <= type(uint64).max). |
DePIN operators buy hardware today to earn protocol rewards over months. Wafer is a financing layer, not an operator β it never runs nodes or takes positions in DePIN networks. Operators who already earn on-chain rewards get upfront HBAR against those future rewards; investors supply that HBAR through pools and earn the blended yield. Centrifuge/Maple, specialized for DePIN.
- Pools are standardized by category Γ risk class (e.g.
GPU-A). The vault is permanent; settled claims are replaced by new ones. The pool share is a NAV-appreciating unit (ERC-4626-like), not a zero-coupon β NAV rises as reward HBAR flows in. - Per-deal APR varies within a class. Each deal carries its own
advance / expected / termβ its own APR. The pool NAV is the blended, realized return of all its deals (minus defaults), accrued amortized-cost. The class is the admin's risk-and-return curation, so each pool stays coherent. (Worked blend in Β§5.3.)
Roles
| Role | Can |
|---|---|
| Investor (allowlisted) | deposit, redeem, requestRedemption / claimRedemption, trade on SaucerSwap |
| Operator (whitelisted) | proposeDeal, escrow the device-NFT at finance, be paid the advance, route rewards |
| Settler (per-claim allowlist) | settleRewards (operator EOA / protocol relayer / owner) |
| Admin (owner = multisig, timelocked) | createPool, approveDeal+assign class, financeClaim, markDefault, pausePool, freeze, adminGrantKyc, registerOperator, setAuthorizedSettler, setMinBuffer |
operator βpropose/escrow/routeββΆβββββββββββββββββββββββββββββββββββββββββdeposit/redeemβ investor
β WaferVault.sol (Hedera EVM, HSCS) β
admin βapprove/finance/classββΆβ @hiero-ledger/hiero-contracts: β
β β’ HTS pool-share (KYC+freeze+fee) β
relayer βsettleRewardsβββββββββΆβ β’ HTS reward-claim NFT (receipt) β
(bridged HNTβHBAR) β β’ device-NFT escrow (collateral) β
MockRewardSource (demo) βββββββΆβ β’ amortized-cost NAV, deposit, β
β redeem+queue, settle, default β
front (Vite+React+viem) ββββββΆββββββββ¬βββββββββββββββββββ¬βββββββββββββ
reads views + Mirror Node β HTS @ 0x167 β shares/WHBAR
ββββββββββββΌββββββ ββββββββββΌβββββββββ
β Hedera HTS β β SaucerSwap V1 β secondary (share/WHBAR)
ββββββββββββ¬ββββββ βββββββββββββββββββ
β reads (logs/balances)
ββββββββββββΌββββββ
β Mirror Node ββββΆ frontend feed + indexer
ββββββββββββββββββ
The vault is the backend: all logic on-chain and verifiable. No HTTP API, no HCS topic β contract events + Mirror Node are the read/audit layer.
- Inside the EVM everything is TINYBAR (1 HBAR = 1e8).
msg.value,address(this).balance,call{value:}are all tinybar (verified by probe).ONE = 1e8, share decimals = 8. - The JSON-RPC boundary uses weibar (1e18). The Hashio relay divides tx
valueby 1e10 β tinybar. Front sendsvalue = N * 1e18weibar (parseEther(N)) for N HBAR; the contract seesN * 1e8tinybar. No hand-scaling inside the contract. Amounts finer than 1 tinybar are truncated by the relay. - Money math is uint256 internally; downcast to int64 only when calling HTS (HTS amounts are
int64). Every payable entrypoint does
require(msg.value <= type(uint64).max, "VALUE_TOO_LARGE"). - All HTS calls must check
responseCode == 22 (SUCCESS)and revert otherwise (a low-level.callreturnssuccess=trueon HTS business errors). UseSafeHTS-style reverting wrappers.
enum DealStatus { Proposed, Approved, Rejected, Financed, Repaid, Defaulted }
enum ClaimStatus { Active, Repaid, Defaulted }
enum PoolStatus { Active, Paused }
enum Category { GPU, Wireless, Mapping, Energy, Storage } // extensible
enum RiskClass { A, B, C }
struct Pool {
address shareToken; // HTS fungible, 8dp, KYC+freeze+fee keys = vault
address claimNft; // HTS NFT collection (the receipts), supply/wipe = vault
Category category;
RiskClass class;
uint256 idleTinybar; // on-hand HBAR cash backing shares (CASH leg)
uint256 receivableTinybar;// Ξ£ carry of Active claims (ACCRUAL leg)
uint256 totalShares; // mirrors HTS supply, 8dp
uint256 queuedShares; // shares waiting in the redemption queue
uint16 minBufferBps; // min idle/(idle+recv) kept free for redemptions (default 0)
PoolStatus status;
}
// totalAssets(pool) = idleTinybar + receivableTinybar (DERIVED β never stored)
struct Deal { // the proposal + its lifecycle
address operator;
bytes32 detailsHash; // keccak of canonical off-chain JSON (company/description/...)
uint256 advanceTinybar; // requested upfront
uint256 expectedTinybar; // total repayment target (expected >= advance)
uint64 termSeconds;
Category category; // proposed
RiskClass class; // ASSIGNED by admin on approve
uint32 poolId; // ASSIGNED by admin on approve
address deviceNft; // collateral collection (escrowed at finance)
int64 deviceSerial;
DealStatus status;
uint256 claimId; // set once financed
}
struct Claim { // the financed receivable (amortized-cost)
uint32 poolId;
address operator;
uint256 advanceTinybar; // initial carrying cost
uint256 expectedTinybar; // face / repayment target
uint256 carryTinybar; // current amortized book value (β0 at Repaid/Default)
uint256 settledTinybar; // cumulative reward HBAR routed in
uint64 startTime; // accretion clock start (= finance time)
uint64 termSeconds;
int64 nftSerial; // claim-NFT serial held by vault
address deviceNft; // escrowed collateral, returned on Repaid
int64 deviceSerial;
ClaimStatus status;
}
struct RedemptionRequest { address investor; uint32 poolId; uint256 shares; uint64 ts; bool filled; }
address public owner; // Ownable2Step; multisig in prod
mapping(uint32 => Pool) public pools; uint32 public poolCount;
mapping(uint256 => Deal) public deals; uint256 public dealCount;
mapping(uint256 => Claim) public claims; uint256 public claimCount;
mapping(address => bool) public isOperator; // whitelist (D9)
mapping(uint256 => mapping(address => bool)) public claimSettler; // per-claim allowlist (D8)
RedemptionRequest[] public redemptionQueue; mapping(uint32 => uint256) public queueHead;
// timelock: mapping(bytes32 => uint64) public pendingAfter; for markDefault/financeClaim
uint256 constant ONE = 1e8; uint8 constant SHARE_DECIMALS = 8;- Contract storage = the source of truth for money (Pool, Deal, Claim, queue). Mutable.
- Claim NFT = the on-chain receipt, one serial per financed deal, held by the vault (treasury), burned at Repaid. Metadata = 32-byte keccak hash of the canonical deal JSON (HTS NFT metadata is β€100 bytes and immutable at mint β no live state can live there).
- Device NFT = the operator's collateral (external collection;
MockDeviceNFTin demo), escrowed into the vault at finance, returned at Repaid, retained/liquidated on Default. - Off-chain (events β Mirror Node) = human-readable display data: company, description,
category, class, advance, expected, term, APR. Emitted in
DealProposed/ClaimFinanced.
Design (locked): finance keeps NAV FLAT (carry-at-advance), NAV rises only by realized spread,
accreted over the term. totalAssets is derived (idle + receivable), so it can't drift β
this kills the old double-count bug class. (IFRS-9 / effective-interest method.)
navPerShare = totalShares == 0 ? ONE : (idle + receivable) * ONE / totalShares
deposit(assets) require(investor KYC'd) // D2 allowlist
shares = totalShares==0 || (idle+recv)==0 ? assets
: assets * totalShares / (idle+recv)
idle += assets; totalShares += shares; mint+transfer shares // NAV flat
financeClaim require(idle >= advance)
idle -= advance; receivable += advance // NAV FLAT (I3)
claim = {carry: advance, expected, settled:0, start: now, term}
escrow deviceNft into vault; mint claim NFT to vault; pay advance LAST (CEI)
settleRewards(pay) require(claimSettler[claimId][msg.sender]); require(c.status==Active)
idle += pay; c.settled += pay
target = c.advance + (c.expected - c.advance) * min(now-c.start, term) / term
newCarry = target > c.settled ? target - c.settled : 0
receivable += newCarry - c.carry; c.carry = newCarry // only spread lifts NAV
if (c.settled >= c.expected) { // full repayment
receivable -= c.carry; c.carry = 0; c.status = Repaid
burn claim NFT; return deviceNft to operator
}
redeem(shares) assets = shares * (idle + receivable) / totalShares
fill = min(assets, liquidAssets) // pay from CASH only (I7)
totalShares -= shares; idle -= fill; burn shares; pay fill LAST
if (assets > fill) enqueue RedemptionRequest(remainder) // D5 queue
markDefault require(c.status==Active) // timelocked (D9)
loss = c.carry; receivable -= loss; c.carry = 0; c.status = Defaulted
retain/wipe deviceNft // NAV falls loss/totalShares
liquidAssets(pool) = idle (optionally minus the minBufferBps reserve). maxRedeem view =
min(userAssets, liquidAssets) so the front never quotes an un-fillable instant redeem.
- I1 cash solvency: Ξ£ pools'
idleβ€address(this).balance;receivableis off-balance (a promise). - I2 asset identity:
totalAssets == idle + receivable, always (derived, no slot to drift). - I3 finance neutrality:
financeClaimleavesidle + receivable(and NAV) unchanged. - I4 receivable composition:
receivable == Ξ£ Active claims' carry. - I5 carry bounds:
0 β€ carry β€ max(advance, expected);carry == 0once Repaid/Defaulted. - I6 no over-recognition: recognized income per claim never exceeds
expected - advance(target clamped to term). - I7 redeem from cash only: instant fill requires
idle β₯ fill; receivables are illiquid β queue. - I8 genesis:
totalShares == 0 β navPerShare == ONE. - I9 units: every money field and every
msg.value/call{value:}is tinybar; no 1e10/1e18 inside. - I10 queue is a senior liability: NAV and share conversions divide over
netAssets = idle + receivable - queuedShares, NEVER grosstotalAssets. A partially-filled redeemer burns ALL their shares atredeembut the unfilled portion's HBAR is earmarked inqueuedSharesand excluded from the base remaining holders share β so a large queued redemption cannot inflate other holders' NAV.claimRedemptiondecrementsidleandqueuedSharesequally, leaving remaining-holder NAV flat.
Single deal (no double-count). deposit 100 β idle100/recv0, NAV 1.000. finance advance 90 (expected 100, 90d) β idle10/recv90, NAV 1.000 (flat). settle 30@t30 β idle40, target 90+10Β·30/90=93.33, carry 63.33, recv63.33, NAV 1.0333. settle 30@t60 β idle70, carry36.67, NAV 1.0667. settle 40@t90 β settled100β₯100 β Repaid, carry0, idle110, recv0, NAV 1.100. (Buggy contract would show 100+100 β NAV 2.0 β the 90 of principal counted twice.)
Blend of 2 deals, different APR (the product premise). deposit 200 β NAV 1.0. Deal A advance90/expected100 (~11%); Deal B advance50/expected60 (20%). finance both β idle60/recv140, NAV 1.0. @t45 settle A 50, B 30 β idle140, carryA45, carryB25, recv70, NAV 1.05. @t90 settle A 50 (βRepaid), B 30 (βRepaid) β idle220, recv0, NAV 1.10. Blended pool return = 10% (the 20 HBAR spread over 200, idle drag included); per-deal APRs (11%, 20%) are absorbed into one pool NAV.
Default. From the blend @t45 (idle140/recv70, NAV1.05), B defaults β loss = carryB 25, recv 45, NAV 0.925. Investors keep cash already received; the unrecovered 25 carry is the realized loss, shared pro-rata. Device-NFT B is retained/liquidated.
Deal: Proposed β(admin approve: assign class + pool)β Approved | Rejected β(admin
finance: escrow device-NFT + advance + mint claim NFT)β Financed(claim Active) β(settle to
expected)β Repaid(claim NFT burned, device-NFT returned) | β(markDefault)β Defaulted(write-down,
device-NFT retained).
Redemption: instant fill up to liquidAssets; remainder β FIFO redemptionQueue, served as
settleRewards/deposit refill idle (a claimRedemption/auto-fill pays queued requests in
order); SaucerSwap is the always-on alternative exit at market price.
| Function | Access | Notes |
|---|---|---|
createPool(category, class, name, symbol) payable |
owner | 2 HTS creates (share-with-fee + NFT), seed dead shares (anti-inflation), grant vault self-KYC. Attach ~100 HBAR, gas 10M. |
registerOperator(addr, bool) |
owner | operator whitelist (D9) |
proposeDeal(category, advance, expected, term, detailsHash, deviceNft, deviceSerial) |
operator | creates Deal{Proposed}; emits DealProposed (full fields) |
approveDeal(dealId, class, poolId) / rejectDeal(dealId) |
owner | assigns class+pool (must match pool category); Approved |
financeClaim(dealId) |
owner (timelocked) | require(idleβ₯advance); pull device-NFT into escrow; mint claim NFT to vault; create Claim; pay advance last (CEI, nonReentrant). Sets default claimSettler = {operator, owner}. |
setAuthorizedSettler(claimId, addr, bool) |
owner | add relayer/keeper to a claim's settler set (D8) |
settleRewards(poolId, claimId) payable |
claim settler | amortized accrual + cap at expected; auto-Repaid+burn+return device-NFT |
markDefault(claimId) |
owner (timelocked) | write down carry; retain device-NFT |
adminGrantKyc(poolId, investor) / adminRevokeKyc(...) |
owner | allowlist (D2) |
deposit(poolId) payable |
investor (KYC'd) | mint shares at NAV; nonReentrant |
redeem(poolId, shares) |
investor | instant fill β€ liquid; queue remainder; approve+transferFrom share pull (fee-exempt); nonReentrant |
claimRedemption(requestId) |
investor | pay a queued request once idle covers it |
pausePool/unpausePool(poolId), freeze/unfreeze(poolId, acct) |
owner | real compliance levers (D10) |
setMinBuffer(poolId, bps) |
owner | redemption buffer (D5) |
| views | β | navPerShare, totalAssets, liquidAssets, maxRedeem, previewDeposit/Redeem, pools/deals/claims getters, queueLength |
Events (full off-chain surface, D12): PoolCreated, OperatorRegistered, DealProposed(dealId, operator, category, advance, expected, term, detailsHash), DealApproved(dealId, class, poolId),
DealRejected, ClaimFinanced(claimId, dealId, poolId, operator, advance, expected, term, serial, deviceNft, deviceSerial), RewardRouted(claimId, amount, newCarry, settled), ClaimRepaid(claimId, serial), ClaimDefaulted(claimId, loss), Deposit, Redeem, RedemptionQueued,
RedemptionFilled, KycGranted/Revoked, Paused/Frozen.
Security (every ship-blocker from the review is addressed):
nonReentrant(OZ) ondeposit/redeem/financeClaim/settleRewards/markDefault/claimRedemption; CEI everywhere (allcall{value:}last). On Hedera EVMcall{value:}triggers recipientreceive()/fallback(), so this is real, not cosmetic.settleRewardsgated toclaimSettler, requiresActive, caps accrual atexpectedβ no NAV spiking/sandwich, no resurrecting a defaulted claim.- Pool seeding (dead shares minted to the vault at
createPool) + virtual offset in share math β first-depositor inflation closed. - uint256 internal accounting,
require(msg.value <= type(uint64).max), downcast only at HTS. - Ownable2Step;
markDefault/financeClaimbehind a timelock (pending β execute window so holders can exit ahead of an adverse action); operator whitelist; owner = multisig in prod. - No custom fee on the share token (D11): on Hedera a fractional fee is assessed on every
non-collector transfer and reverts
INVALID_ACCOUNT_ID, which would break redeem (operatorβvault) and the AMM/secondary transfer (operatorβpair). The share ships as a plain, freely-transferable fungible token (redeem-safe, SaucerSwap-compatible). - Redeem is liquidity-aware (instant β€
liquidAssets, else queue) β no silent bank-run revert.
- Pool-share β
createFungibleTokenWithCustomFeeswith empty fee arrays (no custom fee, D11): 8dp, INFINITE supply, treasury = vault, 5 keys (supply, kyc, freeze, wipe, pause) =KeyValueType.CONTRACT_ID(NO fee_schedule key β no fee, D11).pausePool/unpausePoolcall HTSpauseToken/unpauseToken(real token-level halt of ALL transfers incl. secondary), andfreezeis per-account β so KYC + freeze + pause are three real compliance levers. No fractional fee β a fee breaks redeem + AMM transfers on Hedera (see D11). KYC flow: investor associates (IHRC719) β adminadminGrantKycβ transfers allowed (both parties must be KYC'd; vault self-grants at create). The sameadminGrantKycgrants the SaucerSwap pair KYC when enabling the secondary market. - Claim NFT β
createNonFungibleToken: supply + wipe keys = vault, treasury = vault. Minted to the vault; metadata = 32-byte keccak hash; burned viaburnToken(nft,0,[serial])at Repaid (treasury-held β no transfer needed). - Device NFT β external collection (operator's). Escrow =
transferNFT(device, operator, vault, serial)at finance (operator pre-approves); return at Repaid; wipe/retain at Default. Demo uses aMockDeviceNFTthe operator mints+escrows.
Production target (D3): device-NFT escrow. On Helium each Hotspot is an NFT and the
lazy-distributor recipient.destination PDA decides where rewards go; control of the NFT (or its
destination) = control of the cashflow β an on-chain-enforceable lien aligning who-controls-the-
asset with who-bears-the-credit-risk. Keeper/drip cadence via Hedera HIP-1215 scheduled
transactions (no external keeper). The one irreducible off-chain step is the HNTβHBAR
bridge/swap relayer that calls settleRewards β its trust is custody-of-the-bridged-HBAR only;
state it honestly to judges. (Payout-redirection = trust-the-operator/not enforceable;
keeper-sweep = revocable β both documented as alternatives.)
Demo simulation β MockRewardSource (Hedera, tinybar): the ONLY mock. Models "the escrowed
device-NFT's reward stream, post-bridge."
constructor(address vault)
fund(uint32 poolId, uint256 claimId, uint64 totalRewardTinybar, uint64 startTime,
uint64 termSeconds, uint32 dripCount) payable onlyOwner // prefund + linear schedule
drip(uint256 scheduleId) // permissionless keeper trigger; releases all due intervals via
// vault.settleRewards{value: amt}(poolId, claimId); reverts NOTHING_DUE early
pending(uint256 scheduleId) view returns (uint64 releasableNow, uint64 remaining)
simulateDefault(uint256 scheduleId) onlyOwner // stop mid-term to demo markDefault
armSelfDrip(uint256 scheduleId) payable onlyOwner // HIP-1215: schedule a keeper-free maturity settle
scheduledDrip(uint256 scheduleId) // HSS-fired settlement (releases all due intervals)HIP-1215 scheduled transactions (IMPLEMENTED, live on testnet, system contract 0x16b): two
keeper-free flows beyond the prod-roadmap framing above:
- Locked advance β
WaferVault.setAdvanceLock(seconds); when set,financeClaimkeeps the advance in the vault and schedulesreleaseAdvance(claimId)via HSS to auto-pay the operator at unlock (AdvanceScheduled/AdvanceReleased).releaseAdvanceis permissionless but gated by unlock-time + once-only β the network releases the "locked virement" with no keeper. - Self-scheduled settle β
MockRewardSource.armSelfDripschedules onescheduledDripat maturity that settles the reward in a single network-executed tx (no JS loop). NOTE: HIP-1215 returnsNO_SCHEDULING_ALLOWED_AFTER_SCHEDULED_RECURSIONfor nested/multiple self-schedules per tx, so a per-interval recurring chain is not possible on-chain β it is one scheduled maturity settle; the manualdrip()remains the per-interval path. Proven bypnpm run smoke:hss.MockRewardSourceis added to the claim'sclaimSettlerset. Demo keeper = a script loop (prod = HIP-1215). Demo script: createPool β adminGrantKyc β deposit 100 β proposeDeal β approveDeal(class) β financeClaim(advance 90, expected 100) βfund(100)β loopdrip()asserting NAV 1.0β1.1 monotone (never 2.0) β claim NFT burns; a second run usessimulateDefaultβmarkDefaultβ NAV writes down.
Testnet: RouterV3 0.0.19264 (0xβ¦4b40), Factory 0.0.9959 (0xβ¦26e7), WHBAR token
0.0.15058 (0xβ¦3ad2), WHBAR contract 0.0.15057. Use the HBAR-paired path:
addLiquidityETHNewPool(token, amountTokenDesired, amountTokenMin, amountETHMin, to, deadline)
payable β router wraps HBARβWHBAR internally (no WHBAR association/pre-wrap needed).
- Fee:
factory.pairCreateFee()returns tinycents (~$50); convert live via Mirror Node/api/v1/network/exchangerate(cent_equivalent/hbar_equivalent), +buffer. Never hardcode HBAR. Gas ~3.2M.msg.value = feeInTinybar + HBAR liquidity. - Prereqs:
toneeds a free auto-association slot for the LP token (its id doesn't exist pre-create β can't pre-associate); approve RouterV3 for the share token amount. - KYC deadlock (resolved β verified live): the share token is KYC-keyed, so only the pair
must be KYC-granted before receiving shares. The router does NOT need KYC β a Uniswap-v2 router
transfers the LP leg callerβpair directly (granting the router KYC fails
TOKEN_NOT_ASSOCIATEDand is unnecessary). The atomicaddLiquidityETHNewPoolcan't work (it seeds the fresh un-KYC'd pair in one tx βACCOUNT_KYC_NOT_GRANTED). Working sequence (scripts/enable-secondary.ts, proven on testnet): (1)factory.createPair(share, WHBAR)β permissionless, self-associates the new pair to both tokens, pays the create fee (~$50 live-derived, ~30 HBAR); (2)adminGrantKyc( poolId, pair)β pair now exists+associated so the grant succeeds; (3)approve(router, shareLiq); (4)router.addLiquidityETH(share, β¦)β seeds βpair (LP token to the owner; the vault has no auto-association slot). Liquidity is owner-seeded (admin capital, not pool accounting). Addresses wired once viasetSecondaryConfig(router, whbar, factory)(deploy does this). The in-contract one-callenableSecondaryMarket(poolId, shareLiq, hbarLiq, fee)now implements exactly this sequence and works live (createPair β grantKyc(pair) β mint+approve β addLiquidityETH), fitting Hedera's 15M per-tx gas cap;scripts/enable-secondary.tsis an equivalent fallback. - Seed price (8dp): seed
amountTokenDesiredin share 8dp units vs HBAR so price β NAV. E.g. NAV 1.0 β seed 1000.00000000 shares (1000e8) against 1000 HBAR (value = 1000e18weibar). Compute the share leg in 8dp, the HBAR leg in weibar at the RPC boundary.
SPA in web/, chain 296 via defineChain (RPC https://testnet.hashio.io/api, explorer
hashscan.io/testnet, nativeCurrency.decimals = 18). MetaMask / EIP-6963; no Privy, no backend.
Gas override on HTS-touching calls (gas ~1M, maxFeePerGas = liveBaseFeeΓ5 + tip).
Screens
- Landing β pitch + globe.
- Pools / Fund a category β list pools by
category Γ classwith NAV, TVL, trailing APR; below each, the deals it finances (operators, advance/expected/term/APR) from the Mirror Node feed β "fund the pool" CTA. - Pool detail β NAV chart, deals table, liquidity (idle vs deployed), queue depth.
- Deposit / Redeem widget β association + KYC status surfaced;
maxRedeemshown; queue notice when instant can't fill. - Redemption queue β the wallet's pending requests + position.
- Operator portal β
proposeDealform (company/description/category/advance/expected/term), device-NFT escrow approval, the operator's claims + reward status. - Admin β pending deals review + assign class/pool,
financeClaim,markDefault(with the timelock pending list), KYC allowlist, operator whitelist, pause/freeze,enableSecondaryMarket. - Activity β Mirror Node event feed. Secondary β SaucerSwap swap/exit.
EVM checklist (load-bearing)
- Deposit: (1)
IHRC719(shareToken).associate()if!isAssociated()(selector0x0a754de6, value 0); (2) ensure admin KYC granted (surface status from Mirror Node; else block + request); (3)deposit(poolId)payable withvalue = parseEther(N)(18dp β relay β tinybar); (4) confirm viabalanceOf(8dp) +navPerShare. - Redeem: (1)
approve(vault, shares)on the share token (ERC-20 facade, 8dp units); (2)redeem(poolId, shares)β instant fill + queue; (3) confirm balance/HBAR + allowance consumed. - Wallet/RPC HBAR amounts = 18dp (
parseEther); share amounts = 8dp; never hand-scalemsg.valuein contract math (already tinybar).
web/lib: config.js (chain + addresses + category/class taxonomy), abi.js (vault + ERC-20 +
IHRC719 + SaucerSwap router), format.js (8dp/tinybar + weibar boundary), mirror.js,
errors.js. The app reads live from the deployed VITE_VAULT_ADDRESS (no mock mode at ship).
The shipped contract is the amortized-cost redesign described above. For the record, the key changes
from the earliest prototype were: Pool.totalAssets split into derived idleTinybar +
receivableTinybar (killing the double-count bug); a single financeClaim(poolId, operator, principal) replaced by the proposeDeal β approveDeal β financeClaim(dealId) workflow;
settleRewards made amortized + gated + capped and markDefault writing down carry, not
principal; plus Ownable2Step, a timelock, the operator allowlist, and the redemption queue. All of
this is live and verified β see deployments/testnet.json.
Pure-logic + integration tests must cover: finance-keeps-NAV-flat (I3); time-accretion target;
blended 2-claim different-APR (Β§5.3); default writes down carry not advance;
repaid-residual recognition + clamp at expected; uint64 ceiling require; reentrancy (malicious
operator/receiver); settleRewards access control + Active-only + cap; redeem fee-exemption
(full-share burn succeeds); redemption instant-fill + queue; KYC gating (unassociated/un-KYC'd
deposit reverts); pool-seeding anti-inflation; SaucerSwap seed at NAV + KYC-grant ordering
(testnet/fork). pnpm test (pure math, no network) + pnpm smoke (full lifecycle live, HashScan
links).
Hardhat + toolbox, Solidity 0.8.24 (optimizer + viaIR), network testnet (Hashio, chain 296,
ECDSA operator key). ESM repo β hardhat.config.cts via tsconfig.hardhat.json. pnpm run deploy
(deploy + createPool funded ~100 HBAR, seed dead shares, persist ids + VAULT_ADDRESS),
pnpm run smoke, pnpm run verify <addr> (Sourcify, chain 296). pnpm run deploy not
pnpm deploy (shadowed). .env (gitignored, never committed): OPERATOR_ID/KEY, HASHIO_RPC_URL,
MIRROR_NODE_URL. Keys pasted in chat are treated as exposed β rotate after the event.
IN (ship): the WaferVault contract (amortized-cost, proposal workflow, queue, freeze/pause,
operator whitelist, timelock), native-HBAR settlement, MockRewardSource + MockDeviceNFT, β₯1 pool
(GPU-A) end-to-end (propose β approve β finance+escrow β drip/NAV-rise β repaid/burn; + a default
run), the SaucerSwap share/WHBAR market with KYC enabled, the full frontend (investor + operator
- admin), live + Sourcify-verified on testnet, lifecycle proven by
pnpm smoke.
OUT (roadmap): real per-network reward integrations + the HNTβHBAR bridge relayer, HIP-1215 production keeper, USDC denomination, multi-pool taxonomy expansion, redemption epochs, senior/junior tranches, HCS topic, backend indexer.