Skip to content

Commit 246ef27

Browse files
sktbrdclaude
andauthored
feat(stake): passo "Coletar" — saca o MOR do SplitsWarehouse pra carteira (#236)
O distribute do SplitV2 não empurra direto pra wallet: credita o SplitsWarehouse (ERC-6909) e falta um withdraw. Sem esse passo o MOR "sumia" (ficava preso no warehouse). Agora o fluxo tem 4 passos: Reivindicar → Bridge → Distribuir → Coletar. - mor-split: SPLITS_WAREHOUSE + splitsWarehouseAbi + warehouseMorBalance(owner) (id = uint256(uint160(MOR)); trata <=1 wei como vazio). - use-mor-distribute: collect(owner) → SplitsWarehouse.withdraw(owner, MOR) na Arbitrum; expõe `collecting`. - MorLootbox: lê o crédito no warehouse (collectable), handler onCollect, entra no hasAction/badge — mantém toda a lógica on-chain. - RewardClaimModal: 4º step Coletar + linha de ação; o baú abre quando há reward no split OU no warehouse. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c72544b commit 246ef27

6 files changed

Lines changed: 149 additions & 27 deletions

File tree

messages/en/stake.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,14 @@
178178
"step2": "Bridge",
179179
"step2sub": "LayerZero to Arbitrum (~1–2 min)",
180180
"step3": "Distribute",
181-
"step3sub": "Split 50/25/25 to wallets",
182-
"allCollected": "Nothing to collect right now — all your MOR has been claimed and distributed."
181+
"step3sub": "Split 50/25/25",
182+
"allCollected": "Nothing to collect right now — all your MOR has been claimed and distributed.",
183+
"step4": "Collect",
184+
"step4sub": "Warehouse → your wallet",
185+
"collect": "Collect",
186+
"collecting": "Collecting…",
187+
"collectedTitle": "Collected to your wallet",
188+
"inWarehouse": "in the Warehouse"
183189
},
184190
"admin": {
185191
"title": "Sponsorship vaults",

messages/pt-br/stake.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,14 @@
178178
"step2": "Bridge",
179179
"step2sub": "LayerZero pra Arbitrum (~1–2 min)",
180180
"step3": "Distribuir",
181-
"step3sub": "Divide 50/25/25 nas carteiras",
182-
"allCollected": "Nada pra coletar agora — todo o seu MOR já foi reivindicado e distribuído."
181+
"step3sub": "Divide 50/25/25",
182+
"allCollected": "Nada pra coletar agora — todo o seu MOR já foi reivindicado e distribuído.",
183+
"step4": "Coletar",
184+
"step4sub": "Warehouse → sua carteira",
185+
"collect": "Coletar",
186+
"collecting": "Coletando…",
187+
"collectedTitle": "Coletado na sua carteira",
188+
"inWarehouse": "no Warehouse"
183189
},
184190
"admin": {
185191
"title": "Cofres de patrocínio",

src/components/stake/MorLootbox.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { useUserAddress } from "@/hooks/use-user-address";
2020
import { useMorpheusPosition } from "@/hooks/use-morpheus-position";
2121
import { useMorpheusStake } from "@/hooks/use-morpheus-stake";
2222
import { useMorDistribute } from "@/hooks/use-mor-distribute";
23-
import { predictSplitAddress, splitMorBalance } from "@/lib/mor-split";
23+
import { predictSplitAddress, splitMorBalance, warehouseMorBalance } from "@/lib/mor-split";
2424
import type { MorpheusAsset } from "@/lib/morpheus";
2525
import { RewardClaimModal } from "@/components/stake/RewardClaimModal";
2626

@@ -39,10 +39,11 @@ export function MorLootbox() {
3939
const [nonce, setNonce] = useState(0);
4040
const position = useMorpheusPosition(you, nonce);
4141
const morpheus = useMorpheusStake();
42-
const { distribute, isBusy: distributing } = useMorDistribute();
42+
const { distribute, collect, isBusy: distributing, collecting } = useMorDistribute();
4343

4444
const [open, setOpen] = useState(false);
4545
const [splitBalances, setSplitBalances] = useState<Partial<Record<MorpheusAsset, number>>>({});
46+
const [collectable, setCollectable] = useState(0);
4647
const [busyAsset, setBusyAsset] = useState<MorpheusAsset | null>(null);
4748
// Read the clock once at mount (a lazy initializer is pure-in-render safe) —
4849
// the 7-day unlock doesn't need a live tick; a refresh re-reads it.
@@ -62,13 +63,22 @@ export function MorLootbox() {
6263
return () => { cancelled = true; };
6364
}, [you, position, nonce]);
6465

66+
// MOR the user was credited in the SplitsWarehouse (after a distribute), still
67+
// to be collected into the wallet — the last hop of the reward flow.
68+
useEffect(() => {
69+
if (!you) { setCollectable(0); return; }
70+
let cancelled = false;
71+
warehouseMorBalance(you as Address).then((v) => { if (!cancelled) setCollectable(v); });
72+
return () => { cancelled = true; };
73+
}, [you, nonce]);
74+
6575
const pools = position?.pools ?? [];
6676
const claimable = pools.filter((p) => p.pendingMor > LOOT_MIN_MOR && p.referrer && p.referrer.toLowerCase() !== ZERO);
6777
const distributable = pools.filter((p) => (splitBalances[p.asset] ?? 0) > LOOT_MIN_MOR);
6878
const stakedPools = pools.filter((p) => p.staked > 0);
69-
// The box surfaces whenever there's any MOR to act on — rewards to collect OR
70-
// a principal position to manage (withdraw after the 7-day lock).
71-
const hasAction = claimable.length > 0 || distributable.length > 0 || stakedPools.length > 0;
79+
// The box surfaces whenever there's any MOR to act on — rewards to claim,
80+
// distribute or collect, OR a principal position to manage (7-day lock).
81+
const hasAction = claimable.length > 0 || distributable.length > 0 || collectable > LOOT_MIN_MOR || stakedPools.length > 0;
7282

7383
const refresh = () => setNonce((n) => n + 1);
7484

@@ -110,6 +120,16 @@ export function MorLootbox() {
110120
[distribute, t],
111121
);
112122

123+
const onCollect = useCallback(
124+
async () => {
125+
if (!you) return;
126+
const ok = await collect(you as Address);
127+
if (ok) { toast.success(t("lootbox.collectedTitle")); refresh(); }
128+
else toast.error(t("lootbox.failed"));
129+
},
130+
[you, collect, t],
131+
);
132+
113133
const onWithdraw = useCallback(
114134
async (asset: MorpheusAsset, staked: number) => {
115135
setBusyAsset(asset);
@@ -128,23 +148,26 @@ export function MorLootbox() {
128148

129149
const totalClaimable = claimable.reduce((s, p) => s + p.pendingMor, 0);
130150
const totalDistributable = distributable.reduce((s, p) => s + (splitBalances[p.asset] ?? 0), 0);
131-
const badge = totalClaimable + totalDistributable;
151+
const badge = totalClaimable + totalDistributable + collectable;
132152

133153
return (
134154
<>
135155
{open && (
136156
<RewardClaimModal
137157
claimable={claimable}
138158
distributable={distributable}
159+
collectable={collectable}
139160
stakedPools={stakedPools}
140161
splitBalances={splitBalances}
141162
busyAsset={busyAsset}
142163
morpheusBusy={morpheus.isBusy}
143164
morpheusPhase={morpheus.phase}
144165
distributing={distributing}
166+
collecting={collecting}
145167
nowSec={nowSec}
146168
onClaim={onClaim}
147169
onDistribute={onDistribute}
170+
onCollect={onCollect}
148171
onWithdraw={onWithdraw}
149172
onClose={() => setOpen(false)}
150173
/>

src/components/stake/RewardClaimModal.tsx

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,54 +36,63 @@ function tierFor(total: number): "bronze" | "silver" | "gold" | "black" {
3636
interface Props {
3737
claimable: MorpheusPoolPosition[];
3838
distributable: MorpheusPoolPosition[];
39+
collectable: number;
3940
stakedPools: MorpheusPoolPosition[];
4041
splitBalances: Partial<Record<MorpheusAsset, number>>;
4142
busyAsset: MorpheusAsset | null;
4243
morpheusBusy: boolean;
4344
morpheusPhase: string;
4445
distributing: boolean;
46+
collecting: boolean;
4547
nowSec: number;
4648
onClaim: (asset: MorpheusAsset, referrer: Address) => void;
4749
onDistribute: (asset: MorpheusAsset, referrer: Address) => void;
50+
onCollect: () => void;
4851
onWithdraw: (asset: MorpheusAsset, staked: number) => void;
4952
onClose: () => void;
5053
}
5154

5255
export function RewardClaimModal({
53-
claimable, distributable, stakedPools, splitBalances,
54-
busyAsset, morpheusBusy, morpheusPhase, distributing, nowSec,
55-
onClaim, onDistribute, onWithdraw, onClose,
56+
claimable, distributable, collectable, stakedPools, splitBalances,
57+
busyAsset, morpheusBusy, morpheusPhase, distributing, collecting, nowSec,
58+
onClaim, onDistribute, onCollect, onWithdraw, onClose,
5659
}: Props) {
5760
const t = useTranslations("stake");
5861

5962
const totalClaimable = claimable.reduce((s, p) => s + p.pendingMor, 0);
6063
const totalDistributable = distributable.reduce((s, p) => s + (splitBalances[p.asset] ?? 0), 0);
61-
const total = totalClaimable + totalDistributable;
64+
const total = totalClaimable + totalDistributable + collectable;
6265

63-
const stage: "claim" | "distribute" | "done" =
64-
claimable.length > 0 ? "claim" : distributable.length > 0 ? "distribute" : "done";
66+
const stage: "claim" | "distribute" | "collect" | "done" =
67+
claimable.length > 0 ? "claim"
68+
: distributable.length > 0 ? "distribute"
69+
: collectable > 0 ? "collect"
70+
: "done";
6571

66-
const isPending = busyAsset != null;
67-
// Reward has landed at the split → let the chest reveal it (idle otherwise).
68-
const isOpening = distributable.length > 0 && busyAsset == null;
72+
const isPending = busyAsset != null || collecting;
73+
// Reward is out of the pool (at the split, or credited in the warehouse) → let
74+
// the chest reveal it; idle while a tx runs or nothing's waiting.
75+
const isOpening = (distributable.length > 0 || collectable > 0) && !isPending;
6976

7077
// Clicking the chest runs the primary next action.
7178
const runPrimary = () => {
7279
if (claimable.length > 0) { const p = claimable[0]; onClaim(p.asset, p.referrer); return; }
73-
if (distributable.length > 0) { const p = distributable[0]; onDistribute(p.asset, p.referrer); }
80+
if (distributable.length > 0) { const p = distributable[0]; onDistribute(p.asset, p.referrer); return; }
81+
if (collectable > 0) onCollect();
7482
};
7583

7684
const steps = [
7785
{ key: "claim", label: t("lootbox.step1"), sub: t("lootbox.step1sub") },
7886
{ key: "bridge", label: t("lootbox.step2"), sub: t("lootbox.step2sub") },
7987
{ key: "distribute", label: t("lootbox.step3"), sub: t("lootbox.step3sub") },
88+
{ key: "collect", label: t("lootbox.step4"), sub: t("lootbox.step4sub") },
8089
] as const;
8190

91+
const order = ["claim", "bridge", "distribute", "collect"];
92+
const currentIdx = stage === "claim" ? 0 : stage === "distribute" ? 2 : stage === "collect" ? 3 : order.length;
8293
const stepState = (key: string): "done" | "current" | "idle" => {
83-
if (stage === "done") return "done";
84-
if (stage === "claim") return key === "claim" ? "current" : "idle";
85-
// distribute stage: claim + bridge behind us, distribute is the live step.
86-
return key === "distribute" ? "current" : "done";
94+
const i = order.indexOf(key);
95+
return i < currentIdx ? "done" : i === currentIdx ? "current" : "idle";
8796
};
8897

8998
return (
@@ -173,7 +182,18 @@ export function RewardClaimModal({
173182
</div>
174183
))}
175184

176-
{claimable.length === 0 && distributable.length === 0 && (
185+
{collectable > 0 && (
186+
<div className="flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2">
187+
<span className="text-xs text-muted-foreground">
188+
<b className="font-mono text-foreground">{fmt(collectable)}</b> MOR · {t("lootbox.inWarehouse")}
189+
</span>
190+
<Button size="sm" disabled={collecting} onClick={onCollect} className="h-7">
191+
{collecting ? t("lootbox.collecting") : t("lootbox.collect")}
192+
</Button>
193+
</div>
194+
)}
195+
196+
{claimable.length === 0 && distributable.length === 0 && collectable === 0 && (
177197
<p className="rounded-lg border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">{t("lootbox.allCollected")}</p>
178198
)}
179199
</div>

src/hooks/use-mor-distribute.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ import { ARBITRUM_PUSH_SPLIT_FACTORY, MOR_TOKEN } from "@/lib/morpheus";
2020
import {
2121
splitParamsFor, predictSplitAddress, isSplitDeployed,
2222
pushSplitFactoryAbi, splitWalletAbi, SPLIT_OWNER, SPLIT_SALT,
23+
SPLITS_WAREHOUSE, splitsWarehouseAbi,
2324
} from "@/lib/mor-split";
2425

25-
export type DistributePhase = "idle" | "deploy" | "distribute" | "done" | "error";
26+
export type DistributePhase = "idle" | "deploy" | "distribute" | "collect" | "done" | "error";
2627

2728
export function useMorDistribute() {
2829
const writer = useWriteAccount();
@@ -85,10 +86,47 @@ export function useMorDistribute() {
8586
[writer],
8687
);
8788

89+
/**
90+
* Collect `owner`'s MOR out of the SplitsWarehouse into their wallet — the cut
91+
* `distribute` credited there. `withdraw(owner, token)` is permissionless; the
92+
* caller pays gas and the MOR still only goes to `owner`.
93+
*/
94+
const collect = useCallback(
95+
async (owner: Address): Promise<boolean> => {
96+
if (pending.current) return false;
97+
const client = getThirdwebClient();
98+
if (!client) { setError("Thirdweb not configured."); setPhase("error"); return false; }
99+
if (!writer) { setError("Connect your wallet."); setPhase("error"); return false; }
100+
setError(null);
101+
pending.current = true;
102+
try {
103+
await ensureOnChain(writer.wallet, arbitrum);
104+
setPhase("collect");
105+
const data = encodeFunctionData({
106+
abi: splitsWarehouseAbi, functionName: "withdraw", args: [owner, MOR_TOKEN],
107+
});
108+
const tx = prepareTransaction({ client, chain: arbitrum, to: SPLITS_WAREHOUSE, data });
109+
const hash = (await sendTransaction({ account: writer.account, transaction: tx })).transactionHash;
110+
await waitForReceipt({ client, chain: arbitrum, transactionHash: hash });
111+
setPhase("done");
112+
return true;
113+
} catch (e) {
114+
setError(e instanceof Error ? e.message : "Collect failed.");
115+
setPhase("error");
116+
return false;
117+
} finally {
118+
pending.current = false;
119+
}
120+
},
121+
[writer],
122+
);
123+
88124
return {
89125
distribute,
126+
collect,
90127
phase,
91128
error,
92-
isBusy: phase === "deploy" || phase === "distribute",
129+
isBusy: phase === "deploy" || phase === "distribute" || phase === "collect",
130+
collecting: phase === "collect",
93131
};
94132
}

src/lib/mor-split.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,32 @@ export async function splitMorBalance(staker: Address, athlete: Address): Promis
120120
return 0;
121121
}
122122
}
123+
124+
/**
125+
* The 0xSplits SplitsWarehouse (ERC-6909) — same canonical address on every
126+
* chain. `distribute` doesn't push straight to wallets: it CREDITS each
127+
* recipient's balance in the Warehouse, and a separate `withdraw(owner, token)`
128+
* pushes it out. Warehouse token id = uint256(uint160(token)).
129+
*/
130+
export const SPLITS_WAREHOUSE = getAddress("0x8fb66F38cF86A3d5e8768f8F1754A24A6c661Fb8");
131+
132+
export const splitsWarehouseAbi = [
133+
{ type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "owner", type: "address" }, { name: "id", type: "uint256" }], outputs: [{ type: "uint256" }] },
134+
{ type: "function", name: "withdraw", stateMutability: "nonpayable", inputs: [{ name: "owner", type: "address" }, { name: "token", type: "address" }], outputs: [] },
135+
] as const;
136+
137+
/**
138+
* MOR credited to `owner` in the SplitsWarehouse after a distribute, waiting to
139+
* be collected into the wallet. The Warehouse leaves 1 wei — treat <= 1 as empty.
140+
*/
141+
export async function warehouseMorBalance(owner: Address): Promise<number> {
142+
try {
143+
const bal = await arbitrumClient.readContract({
144+
address: SPLITS_WAREHOUSE, abi: splitsWarehouseAbi, functionName: "balanceOf",
145+
args: [getAddress(owner), BigInt(MOR_TOKEN)],
146+
});
147+
return Number(formatUnits(bal <= BigInt(1) ? BigInt(0) : bal, MOR_DECIMALS));
148+
} catch {
149+
return 0;
150+
}
151+
}

0 commit comments

Comments
 (0)