// app-delegate.jsx — Flow B (Delegating User) + Flow C (Solo Custodied Voter) // Exports to window: DelegateScreen, useDelegateData const { useState, useEffect, useMemo } = React; const { Card, useMobile, useCountdown, TokenPair, useChain, useLoad, useEpoch, usePairs } = window; const { fmt: dfmt, ethers: DE } = window.Chain; /* ── shared bits ─────────────────────────────────────────────────────── */ function DgCall({ children }) { return
TX  {children}
; } function DgLabel({ children, accent }) { return (
{children}
); } function DgBadge({ children, color = 'var(--ink-dim)', solid }) { return {children}; } function DgBtn({ children, onClick, accent, kind = 'ghost', disabled, full, title }) { const bg = kind === 'primary' ? accent : kind === 'danger' ? 'rgba(239,68,68,0.1)' : 'rgba(232,230,225,0.05)'; const bd = kind === 'primary' ? accent : kind === 'danger' ? 'rgba(239,68,68,0.35)' : 'rgba(232,230,225,0.14)'; const fg = kind === 'primary' ? '#fff' : kind === 'danger' ? '#ef4444' : 'var(--ink-dim)'; return ( ); } function DgBox({ children, style, tone }) { const c = tone === 'warn' ? 'rgba(255,212,124,' : tone === 'bad' ? 'rgba(239,68,68,' : 'rgba(232,230,225,'; return
{children}
; } function DgInfo({ text }) { const [on, setOn] = useState(false); return ( setOn(true)} onMouseLeave={() => setOn(false)} onClick={e => { e.stopPropagation(); setOn(v => !v); }}> i {on && ( {text} )} ); } function DgEmpty({ children }) { return
{children}
; } /* ── data ────────────────────────────────────────────────────────────── */ const NO_KEY = '0x0000000000000000000000000000000000000000000000000000000000000000'; const vpNum = (v) => dfmt.toNum(v, 18); async function readTiers(fd) { const tiers = []; for (let i = 0; i < 12; i++) { try { const t = await fd.tier(i); tiers.push({ t: i, vp: t.amountNeeded, disc: Number(t.feeDiscount) }); } catch { break; } } return tiers; } function tierFor(tiers, vp) { let t = null; for (const x of tiers) if (vp >= x.vp) t = x; return t; } const discPct = (d) => (d / 1e16).toFixed(0) + '%'; function useDelegateData() { const chain = useChain(); const pairData = usePairs().data; const gauges = useMemo(() => (pairData?.pairs ?? []).filter(p => p.gauge && p.gauge !== DE.ZeroAddress), [pairData]); const gaugeKey = gauges.map(g => g.gauge).join(','); return useLoad(async (c) => { const book = c.custody.target; const byGauge = Object.fromEntries(gauges.map(g => [g.gauge.toLowerCase(), g])); const labelOf = (src) => { const g = byGauge[src.toLowerCase()]; return g ? `${g.token0_symbol}/${g.token1_symbol} LP` : dfmt.short(src); }; const usdOf = (src, amount) => { const g = byGauge[src.toLowerCase()]; return g ? vpNum(amount) * g.lpPrice : 0; }; const [tiers, activePeriod, delegatorAddrs] = await Promise.all([ readTiers(c.feeDiscount), c.minter.active_period(), c.delegatorLens.registeredDelegators(), ]); const wallet = []; const approved = {}; let custody = []; let userTier = 0, effectiveBalance = 0n, blockedUntil = 0; if (chain.account) { for (const g of gauges) { const ve = c.at('VeGauge', g.gauge); const [n, ok] = await Promise.all([ve.balanceOf(chain.account), ve.isApprovedForAll(chain.account, book)]); approved[g.gauge.toLowerCase()] = ok; for (let i = 0; i < Number(n); i++) { const id = await ve.tokenOfOwnerByIndex(chain.account, i); const [amount, vp, voted] = await Promise.all([ve.getAmount(id), ve.getVotingPowerView(id), ve.voted(id)]); wallet.push({ key: `${g.gauge}:${id}`, gauge: g.gauge, tokenId: id, label: labelOf(g.gauge), amount, vp, voted, usd: usdOf(g.gauge, amount) }); } } const rows = await c.delegatorLens.userPositions(chain.account); custody = await Promise.all(rows.map(async (r) => { const [exit, epochsVoted, vp, sched] = await Promise.all([ c.delegatorLens.exitInfo(chain.account, r.nftKey), c.custody.getNftEpochsVoted(r.nftKey), c.at('VeGauge', r.source).getVotingPowerView(r.tokenId), c.groupVotingSource.isWithdrawalScheduled(r.nftKey), ]); const voteActive = epochsVoted.some(e => e === activePeriod); return { key: r.nftKey, nftKey: r.nftKey, source: r.source, tokenId: r.tokenId, amount: r.amount, vp, label: labelOf(r.source), usd: usdOf(r.source, r.amount), delegated: r.delegated, groupKey: r.groupKey, delegator: r.delegator, registered: r.benefitContract !== DE.ZeroAddress, benefitValue: r.benefitValue, unlockStartedAt: Number(r.unlockStartedAt), unlockReadyAt: Number(r.unlockReadyAt), voteActive, state: r.delegated ? 'delegated' : voteActive ? 'self' : 'custodied', exit: { unlockStarted: exit.unlockStarted, unlockReadyAt: Number(exit.unlockReadyAt), secondsUntilFree: Number(exit.secondsUntilFree), instantExitFeeBps: Number(exit.instantExitFeeBps), groupVotedThisEpoch: exit.groupVotedThisEpoch, canInstantExitNow: exit.canInstantExitNow, canFinishUnlockNow: exit.canFinishUnlockNow, blocker: exit.blocker, withdrawalScheduled: exit.withdrawalScheduled || sched, }, }; })); [userTier, effectiveBalance, blockedUntil] = await Promise.all([ c.feeDiscount.userToLvl(chain.account), c.feeDiscount.effectiveBalance(chain.account), c.feeDiscount.registrationBlockedUntil(chain.account), ]); } const delegators = await Promise.all(delegatorAddrs.map(async (a) => { const d = await c.delegatorLens.delegatorInfo(a); const groups = await Promise.all(gauges.map(g => c.delegatorLens.group(a, g.gauge).catch(() => null))); const live = groups.filter(g => g && g.groupKey !== NO_KEY); return { id: a, address: a, name: dfmt.short(a), fee: Number(d.delegatorFeeRate) / 1e16, tier: Number(d.tier), active: d.isActive && d.registered, missed: Number(d.missedEpochs), lockedArch: d.lockedArch, group: live.reduce((s, g) => s + g.members.length, 0), vp: live.reduce((s, g) => s + g.aggregatedPower, 0n), byUnderlying: Object.fromEntries(live.map(g => [g.underlying.toLowerCase(), { groupKey: g.groupKey, votedThisEpoch: g.votedThisEpoch, joinOpen: g.joinOpen, operable: g.operable, voteOpensAt: Number(g.voteOpensAt) }])), }; })); return { wallet, approved, custody, delegators, tiers, userTier: Number(userTier), effectiveBalance, blockedUntil: Number(blockedUntil), activePeriod, gauges }; }, [gaugeKey]); } // Rewards for one custodied position, epoch by epoch. function useRewardRows(pos, account) { return useLoad(async (c) => { if (!pos || !account) return null; if (!pos.delegated) { const count = Number(await c.soloRewardsHarvester.votedEpochCount(pos.nftKey)); return { solo: true, count }; } const epochs = [...await c.groupVotingSource.getGroupEpochsVoted(pos.groupKey)].slice(-6).reverse(); const rows = []; for (const epoch of epochs) { const pools = await c.groupVotingSource.getGroupPoolsVotedInEpoch(pos.groupKey, epoch); const items = []; for (const pool of pools) { for (const internal of [false, true]) { const bribe = await c.delegatorRewardManager.bribeForEpoch(internal, pool, epoch); if (bribe === DE.ZeroAddress) continue; const tokens = await c.at('Bribe', bribe).getRewardTokensForEpoch(epoch); if (!tokens.length) continue; const pend = await c.delegatorLens.pendingBribeRewards(pos.groupKey, internal, pool, epoch, tokens); for (let i = 0; i < tokens.length; i++) { const share = await c.delegatorLens.memberBribeShare(pos.groupKey, bribe, pool, tokens[i], epoch, account); const sym = await c.at('ERC20', tokens[i]).symbol().catch(() => dfmt.short(tokens[i])); const dec = Number(await c.at('ERC20', tokens[i]).decimals().catch(() => 18)); items.push({ pool, internal, bribe, token: tokens[i], sym, dec, settled: pend[i].settled, settleable: pend[i].settleable, groupTotal: pend[i].groupTotal, memberPool: pend[i].memberPool, share: share.share, claimed: share.claimed }); } } } rows.push({ epoch, items }); } return { solo: false, rows }; }, [pos?.nftKey, pos?.groupKey]); } /* ── SCREEN ──────────────────────────────────────────────────────────── */ function DelegateScreen({ accent, onTx, onNav, dev = true }) { const isMobile = useMobile(); const chain = useChain(); const epoch = useEpoch(); const ep = useCountdown(epoch.endsAt); const { data, loading, error } = useDelegateData(); const [tab, setTab] = useState('positions'); const [posPicker, setPosPicker] = useState(false); const [sel, setSel] = useState(null); const [pickedDelegator, setPicked] = useState(null); const [regFee, setRegFee] = useState(false); const [forceDialog, setForceDialog] = useState(false); const wallet = data?.wallet ?? []; const custody = data?.custody ?? []; const delegators = data?.delegators ?? []; const tiers = data?.tiers ?? []; const selPos = custody.find(p => p.key === sel) ?? custody[0] ?? null; const joinCopy = ep.ready ? `${ep.d}d ${String(ep.h).padStart(2,'0')}h` : '—'; const fdAddr = chain.book?.contracts.feeDiscount; const custodyAddr = chain.book?.contracts.custody; const nowSec = Math.floor(Date.now() / 1000); const myTier = tiers[data?.userTier ?? 0] ?? null; function approveGauge(n) { onTx('setApprovalForAll', (w) => w.at('VeGauge', n.gauge).setApprovalForAll(custodyAddr, true)); } function deposit(n) { onTx('depositNFT', (w) => w.custody.depositNFT(n.gauge, n.tokenId)); } function delegate() { if (!pickedDelegator || !selPos) return; onTx('delegateNFTs', (w) => w.delegatorVotingManager.delegateNFTs(selPos.source, [selPos.nftKey], pickedDelegator.address, regFee)); setTab('positions'); } function register(p) { onTx('registerForBenefit', (w) => w.custody.registerForBenefit(p.nftKey, fdAddr)); } function selfReset(p) { onTx('resetVotes', (w) => w.custody.selfResetVotes(p.nftKey)); } function leaveGroup(p) { onTx('withdrawNFTs', (w) => w.delegatorVotingManager.withdrawNFTs(p.source, p.delegator, [p.nftKey])); } function withdrawFromCustody(p) { onTx('withdrawNFT', (w) => w.custody.withdrawNFT(p.nftKey)); } function schedule(p) { onTx('scheduleWithdrawal', (w) => w.delegatorVotingManager.scheduleWithdrawal(p.source, p.delegator, [p.nftKey])); } function executeScheduled(p) { onTx('executeScheduledWithdrawal', (w) => w.groupVotingSource.executeScheduledWithdrawal([p.nftKey])); } function startUnlock(p) { onTx('startUnlock', (w) => w.feeDiscount.startUnlockDelegatedNFT(chain.account, p.nftKey)); } function finishUnlock(p) { onTx('finishUnlock', (w) => w.feeDiscount.finishUnlockDelegatedNFT(chain.account, p.nftKey)); } function forceUnlock(p) { onTx('forceUnlock', (w) => w.feeDiscount.forceUnlockDelegatedNFT(chain.account, p.nftKey)); } const TABS = [['positions','POSITIONS'],['delegators','DELEGATORS'],['rewards','REWARDS'],['exit','EXIT']]; const stateOf = (p) => p.state === 'delegated' ? { txt:`DELEGATED · ${dfmt.short(p.delegator)}`, color:accent } : p.state === 'self' ? { txt:'SELF-VOTING', color:'#4ade80' } : { txt:'CUSTODIED · NOT DELEGATED', color:'#ffd47c' }; const posLine = (p) => `${p.label} — ${dfmt.units(p.amount, 18, 2)} LP`; const posSub = (p) => `${dfmt.usd(p.usd)} · VP ${dfmt.num(vpNum(p.vp), 0)}`; return (
{/* header */}
CUSTODY & DELEGATION
DELEGATE
Hand voting to a professional delegator, or keep your position in custody and vote for yourself. Positions are held by DelegatorCustody — they leave your wallet.
{!chain.connected && Connect a wallet to see your positions.} {error && {error}} {/* subtabs */}
{TABS.map(([k,l]) => ( ))}
{/* ── POSITIONS ── */} {tab === 'positions' && (
STEP 1 · DEPOSIT INTO CUSTODY
Approval is granted per veGauge — one per gauge, then every position from it deposits in a click.
{loading && !data && LOADING…} {data && wallet.length === 0 && {chain.connected ? 'NO POSITIONS LEFT IN WALLET' : 'CONNECT WALLET'}} {wallet.map(n => { const ok = data.approved[n.gauge.toLowerCase()]; return (
NFT #{n.tokenId.toString()} · {posLine(n)} {ok ? ✓ GAUGE APPROVED : GAUGE NOT APPROVED} {n.voted && 🔒 VOTE ACTIVE}
{posSub(n)} · IN WALLET
{ok ? deposit(n)} disabled={n.voted} title={n.voted ? 'Reset votes on the Vote screen first' : undefined}>DEPOSIT : approveGauge(n)}>APPROVE GAUGE}
); })} {dev && VeGauge[{'{'}gauge{'}'}].setApprovalForAll(custody, true) — one per gauge → DelegatorCustody.depositNFT(underlyingVeGauge, tokenId) → nftKey}
Only the owner can deposit — approved operators can't. Positions land custodied but not delegated.
STEP 2 · YOUR CUSTODIED POSITIONS
{data && custody.length === 0 &&
NOTHING IN CUSTODY YET
} {custody.map((p) => { const st = stateOf(p); const on = selPos?.key === p.key; return (
setSel(p.key)} style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:12, flexWrap:'wrap', padding:'14px 22px', borderTop:'1px solid rgba(232,230,225,0.06)', cursor:'pointer', background: on ? 'rgba(232,230,225,0.035)' : 'transparent', borderLeft:`2px solid ${on ? accent : 'transparent'}`, }}>
NFT #{p.tokenId.toString()} · {posLine(p)} {st.txt} {p.registered && FEE TIER REGISTERED} {p.voteActive && 🔒 VOTE ACTIVE} {p.exit.withdrawalScheduled && WITHDRAWAL SCHEDULED}
{posSub(p)}
{p.state === 'custodied' && <> { setSel(p.key); setTab('delegators'); }}>DELEGATE onNav && onNav('vote')}>SELF-VOTE { setSel(p.key); setTab('exit'); }}>WITHDRAW } {p.state !== 'custodied' && <> { setSel(p.key); setTab('rewards'); }}>REWARDS { setSel(p.key); setTab('exit'); }}>EXIT }
); })}
SOLO CUSTODIED VOTER — OPTIONAL
A custodied position can vote for itself, with no delegator. This is the resting state after a custody deposit and the way to reach the fee-tier program without giving up your vote.
SELF-VOTE
Allocate this position's weight across gauges yourself, once per epoch. Custodied positions appear in the Vote screen's NFT picker.
{dev && DelegatorCustody.selfVote(nftKey, [pools], [weights])}
onNav && onNav('vote')}>OPEN GAUGE ALLOCATION
REGISTER FOR FEE TIER
Swap-fee discount on all pairs. Costs a separate 2-week unlock lockup. {selPos ? <>
Selected: NFT #{selPos.tokenId.toString()} · {selPos.label}{selPos.registered ? ' — already registered' : selPos.delegated ? ' — delegated, register via delegation' : ''} : null} {data && data.blockedUntil > nowSec && <>
Registration blocked for {dfmt.countdown(data.blockedUntil - nowSec)} after a forced unlock.}
{dev && DelegatorCustody.registerForBenefit(nftKey, feeDiscountV4)}
nowSec} onClick={() => register(selPos)}>REGISTER
)} {/* ── DELEGATORS ── */} {tab === 'delegators' && (
PICK A DELEGATOR
DELEGATING: {selPos ? posLine(selPos) : 'none selected'}
{posPicker && (
YOUR CUSTODIED POSITIONS
{custody.map(p => { const on = selPos?.key === p.key; const st = p.state === 'delegated' ? { txt:`ALREADY DELEGATED · ${dfmt.short(p.delegator)}`, color:accent } : p.state === 'self' ? { txt:'SELF-VOTING · RESET VOTES BEFORE DELEGATING', color:'#4ade80' } : { txt:'CUSTODIED · READY TO DELEGATE', color:'#ffd47c' }; return ( ); })} {custody.length === 0 &&
NOTHING IN CUSTODY YET
}
)} {!isMobile && (
{['DELEGATOR','FEE','GROUP','AGGREGATED VP','MISSED','THIS EPOCH'].map(h => (
{h}
))}
)} {data && delegators.length === 0 &&
NO REGISTERED DELEGATORS YET
} {delegators.map(d => { const g = selPos ? d.byUnderlying[selPos.source.toLowerCase()] : null; const votedThisEpoch = !!g?.votedThisEpoch; const blocked = votedThisEpoch || !d.active || (g && !g.joinOpen); const picked = pickedDelegator?.id === d.id; return (
!blocked && setPicked(d)} style={{ display:'grid', gridTemplateColumns: isMobile?'1fr':'1.3fr 60px 70px 110px 70px 1.1fr', gap: isMobile?6:0, padding:'13px 20px', borderTop:'1px solid rgba(232,230,225,0.05)', alignItems:'center', cursor: blocked ? 'not-allowed' : 'pointer', opacity: blocked ? 0.45 : 1, background: picked ? `${accent}0f` : 'transparent', borderLeft: picked ? `2px solid ${accent}` : '2px solid transparent', }}>
{d.name}
TIER {d.tier} · {dfmt.units(d.lockedArch, 18, 0)} ARCH LOCKED
{isMobile && 'FEE '}{d.fee}%
{isMobile && 'GROUP '}{d.group}
{dfmt.num(vpNum(d.vp), 0)}
0 ? '#ffd47c' : 'var(--ink-dim)' }}>{d.missed}/3
{!d.active ? No longer accepting delegations. : votedThisEpoch ? Already voted this epoch — join in {joinCopy}. : g && !g.joinOpen ? Group closed to new members right now. : Open — {g ? "hasn't voted yet" : 'no group for this gauge yet, one opens on delegation'}}
); })}
OPTIONAL · FEE DISCOUNT PROGRAM
EXIT WITHOUT REGISTERING
2 transactions · immediate · no penalty
{dev && withdrawNFTs(...) → DelegatorCustody.withdrawNFT(nftKey)}
EXIT AFTER REGISTERING
2 transactions + 2-week wait, or pay a 5% VP penalty to leave now (plus a 2-week cooldown before re-registering anything).
{dev && startUnlockDelegatedNFT → finishUnlockDelegatedNFT}
{['TIER','ARCH-NORMALIZED VP','SWAP-FEE DISCOUNT'].map(h => (
{h}
))}
{tiers.map(t => { const mine = data && t.t === data.userTier && chain.connected; return (
{t.t}
{dfmt.units(t.vp, 18, 0)}
{discPct(t.disc)}{mine && ' ← your tier'}
); })} {tiers.length === 0 && {data ? 'NO TIERS CONFIGURED' : 'LOADING…'}}
Thresholds are read on-chain from FeeDiscountV4. {data && chain.connected ? <>Your registered VP: {dfmt.units(data.effectiveBalance, 18, 0)}. : null} Your tier moves only when the manager calls registerDelegatedVP — not with your live gauge balance.
{!pickedDelegator && Pick an open delegator to continue. Rows that already voted this epoch will revert on-chain.} {selPos?.voteActive && !selPos.delegated && This position voted this epoch. Reset its votes on the Exit tab before delegating.} {pickedDelegator ? `DELEGATE TO ${pickedDelegator.name}` : 'SELECT A DELEGATOR'} {dev && DelegatorVotingManager.delegateNFTs(underlying, [nftKeys], delegator, registerToFeeDiscount={String(regFee)})}
)} {/* ── REWARDS ── */} {tab === 'rewards' && } {/* ── EXIT ── */} {tab === 'exit' && (
STEP 1 · WHICH POSITION ARE YOU EXITING?
Every position exits on its own terms. What it costs you depends on where it sits right now — delegated or self-voting, registered for the fee discount or not.
{custody.map(p => { const on = selPos?.key === p.key; const tier = p.registered ? tierFor(tiers, p.benefitValue) : null; const st = stateOf(p); const route = p.registered ? { txt:'PATH B or C · registered for the fee discount', color:'#ffd47c' } : { txt:'PATH A · immediate, no penalty', color:'#4ade80' }; return ( ); })} {data && custody.length === 0 &&
NOTHING IN CUSTODY TO EXIT
}
{selPos && (
STEP 2 · EXIT NFT #{selPos.tokenId.toString()} · {selPos.label} {selPos.registered ? 'REGISTERED FOR THE FEE DISCOUNT — THE LOCKUP APPLIES' : 'NOT REGISTERED — NO LOCKUP'}
)} {selPos?.voteActive && !selPos.delegated && (
THIS POSITION HAS AN ACTIVE VOTE
Reset votes to withdraw. Withdrawal is blocked until the reset confirms.
selfReset(selPos)}>RESET VOTES
{dev && revert PositionVoteActive(nftKey, tokenId) — decoded by the tx modal · selfResetVotes(nftKey)}
)}
{selPos && !selPos.registered && (
PATH A · STRAIGHT OUT IMMEDIATE
{selPos.delegated ? '2 transactions, no wait, no penalty. Pull out of the group, then pull out of custody.' : '1 transaction. The position goes straight back to your wallet.'} {selPos.delegated && selPos.exit.groupVotedThisEpoch && <>
The group already voted this epoch — leaving now is blocked until the epoch rolls. Use the escape hatch instead.}
{dev && {selPos.delegated ? 'withdrawNFTs(underlying, delegator, [nftKeys]) → ' : ''}DelegatorCustody.withdrawNFT(nftKey)}
{selPos.delegated ? leaveGroup(selPos)}>1 · LEAVE GROUP : withdrawFromCustody(selPos)}>WITHDRAW NOW} {selPos.delegated && 2 · WITHDRAW FROM CUSTODY}
)} {selPos?.delegated && (
PATH A′ · ESCAPE HATCH ~1 EPOCH
Your delegator went quiet? Schedule the withdrawal and execute it after one epoch. The second call is permissionless — nobody can hold your position hostage.
{dev && scheduleWithdrawal(underlying, delegator, [nftKeys]) → GroupVotingSource.executeScheduledWithdrawal([nftKeys])}
schedule(selPos)}>{selPos.exit.withdrawalScheduled ? 'SCHEDULED' : 'SCHEDULE WITHDRAWAL'} executeScheduled(selPos)}>EXECUTE
)} {selPos?.registered && (
PATH B · REGISTERED, PATIENT 2 WEEKS · FREE
Start the unlock, come back in two weeks, finish it. No penalty.
{selPos.exit.unlockStarted && (
{selPos.exit.canFinishUnlockNow ? 'UNLOCK READY' : 'UNLOCK COMPLETES IN'}
{selPos.exit.canFinishUnlockNow ? 'NOW' : dfmt.countdown(Math.max(0, selPos.exit.unlockReadyAt - nowSec))}
)} {dev && FeeDiscountV4.startUnlockDelegatedNFT(user, nftKey) → finishUnlockDelegatedNFT(user, nftKey)}
startUnlock(selPos)} disabled={selPos.exit.unlockStarted}>{selPos.exit.unlockStarted ? 'UNLOCK STARTED' : 'START UNLOCK'} finishUnlock(selPos)} title={selPos.exit.unlockStarted && !selPos.exit.canFinishUnlockNow ? `Available in ${dfmt.countdown(Math.max(0, selPos.exit.unlockReadyAt - nowSec))}` : undefined}>FINISH UNLOCK
)} {selPos?.registered && (
PATH C · OUT NOW {selPos.exit.instantExitFeeBps ? (selPos.exit.instantExitFeeBps / 100) + '%' : '5%'} PENALTY
Force the unlock immediately. Costs {selPos.exit.instantExitFeeBps ? (selPos.exit.instantExitFeeBps / 100) + '%' : '5%'} of your tier VP and blocks re-registering any position for 2 weeks. {!selPos.exit.canInstantExitNow && selPos.exit.blocker && <>
{selPos.exit.blocker}}
{dev && FeeDiscountV4.forceUnlockDelegatedNFT(user, nftKey)}
setForceDialog(true)}>FORCE UNLOCK
)}
)}
{forceDialog && (
setForceDialog(false)} style={{ position:'fixed', inset:0, zIndex:420, background:'rgba(5,5,5,0.9)', backdropFilter:'blur(6px)', display:'flex', alignItems:'center', justifyContent:'center', padding:16 }}>
e.stopPropagation()} style={{ width:'100%', maxWidth:460, background:'rgba(8,6,5,0.98)', border:'1px solid rgba(239,68,68,0.35)', padding:'26px 26px 22px' }}>
FORCE UNLOCK — TWO COSTS
{[ ['VP PENALTY', 'Taken against your tier voting power, not your tokens. Your fee tier may drop.'], ['2-WEEK COOLDOWN', "You won't be able to register ANY position for the fee discount for two weeks. This is the part people don't expect."], ].map(([t,b]) => (
{t}
{b}
))}
setForceDialog(false)}>CANCEL { setForceDialog(false); forceUnlock(selPos); }}>I ACCEPT BOTH — FORCE UNLOCK
)}
); } function RewardsTab({ accent, pos, onTx, dev, isMobile, activePeriod }) { const chain = useChain(); const { data, loading } = useRewardRows(pos, chain.account); const epochNo = (e) => chain.book && data ? Math.floor(Number(e) / window.Chain.WEEK) : '—'; function claimSolo(count) { onTx('claimRewardsForEpochs', (w) => w.custody.claimRewardsForEpochs(pos.nftKey, 0, count)); } function settle(it, epoch) { onTx('settleEpoch', (w) => w.delegatorRewardManager.claimGroupRewards(pos.groupKey, it.internal, it.pool, it.token, epoch)); } function claimMember(it, epoch) { onTx('claimRewardAsMember', (w) => w.delegatorRewardManager.claimRewardAsMember(pos.groupKey, it.bribe, it.pool, it.token, epoch, chain.account)); } function claimLp() { onTx('claimGroupLPEmissions', (w) => w.delegatorRewardManager.claimGroupLPEmissions(pos.groupKey, pos.source, 0, 20)); } return (
Nothing is pushed to you. Rewards are claimed per epoch, per type — there is no single running total.
{!pos && PICK A CUSTODIED POSITION ON THE POSITIONS TAB} {pos && pos.delegated && (
CLAIMABLE BY EPOCH — NFT #{pos.tokenId.toString()} · {pos.label} CLAIM GROUP LP EMISSIONS
{['EPOCH','POOL · SOURCE','GROUP TOTAL','YOUR SHARE',''].map(h => (
{h}
))}
{loading && !data &&
LOADING…
} {data && data.rows.length === 0 &&
THE GROUP HAS NOT VOTED YET
} {data?.rows.map(r => r.items.length === 0 ?
{epochNo(r.epoch)}
Voted · no bribes or fees posted for this epoch
: r.items.map((it, i) => (
{epochNo(r.epoch)}
{dfmt.short(it.pool)} · {it.internal ? 'TRADING FEES' : 'BRIBES'} · {it.sym}
{it.settled ? `${dfmt.units(it.groupTotal, it.dec)} ${it.sym}` : '—'}
{it.settled ? `${dfmt.units(it.share, it.dec)} ${it.sym}` : 'not settled'}
{it.settled ? claimMember(it, r.epoch)}>{it.claimed ? 'CLAIMED' : 'CLAIM'} : settle(it, r.epoch)}>SETTLE}
)))}
Not settled means nobody has pulled the group's reward for that epoch yet. Settlement is permissionless once the epoch closes — anyone, including you, can trigger it. {dev && claimGroupRewards(groupKey, internal, pool, token, epoch) · claimRewardAsMember(groupKey, bribe, pool, token, epoch, self) · claimGroupLPEmissions(groupKey, gauge, 0, 20)}
)} {pos && !pos.delegated && ( SELF-VOTED POSITION · PAGINATED CLAIM — NFT #{pos.tokenId.toString()}
{data ? `${data.count} voted epoch${data.count === 1 ? '' : 's'} to collect.` : 'Counting voted epochs…'} Claiming walks the epochs in batches of 20 and is idempotent per epoch — safe to re-run.
claimSolo(Math.min(20, data.count))} disabled={!data || data.count === 0}> {data && data.count > 0 ? `CLAIM ${Math.min(20, data.count)} EPOCH${data.count === 1 ? '' : 'S'}` : 'NOTHING TO CLAIM YET'} {dev && read SoloRewardsHarvester.votedEpochCount(nftKey) → DelegatorCustody.claimRewardsForEpochs(nftKey, 0, count)}
)}
); } Object.assign(window, { DelegateScreen, useDelegateData });