Skip to content

Commit 77c9626

Browse files
authored
fix(stake): type-1 LayerZero adapter params so MOR claims can quote (#266)
Every MOR claim died at the fee quote: LZ_ADAPTER_PARAMS was "0x", the classic V1 "use the pathway default" — but the V1 endpoint now routes through the V2-era UltraLightNode, which converts adapter params into worker options and rejects empty bytes. estimateFees reverted with LZ_ULN_InvalidWorkerOptions (0x6592671c) before any tx was sent, and the user saw a raw selector dump. Reproduced both ways by direct RPC: "0x" reverts; type-1 params quote fine (200k -> 0.0000481 ETH, 350k -> 0.0000511, 500k -> 0.0000541). - LZ_ADAPTER_PARAMS becomes encodePacked(uint16(1), uint256(350000)), byte-for-byte the encoding proven against the endpoint. 350k is deliberately conservative, not a documented Morpheus figure: the quote only sets the attached value, the send's real destination gas comes from the L1Sender's stored config, and excess msg.value is refunded. - The ULN's revert vocabulary joins lzEndpointAbi so a future failure decodes to a name instead of a selector. - A failed quote now reads "Couldn't price the bridge fee" instead of a viem revert dump — quoting can fail for reasons that are ours, not the user's.
1 parent a8c992e commit 77c9626

2 files changed

Lines changed: 146 additions & 39 deletions

File tree

src/hooks/use-morpheus-stake.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,18 @@ export function useMorpheusStake() {
410410
setPhase("error");
411411
return false;
412412
}
413-
const fee = await quoteClaimFee(account.address as Address, pending_);
413+
// Quoting can fail for reasons that have nothing to do with the user
414+
// (the LayerZero pathway rejecting our params is how every claim died
415+
// for a while) — and a raw viem revert dump with a selector in it is
416+
// not an error message. Name the step that failed instead.
417+
let fee: bigint;
418+
try {
419+
fee = await quoteClaimFee(account.address as Address, pending_);
420+
} catch {
421+
setError("Couldn't price the bridge fee (mainnet → Arbitrum). Try again in a moment.");
422+
setPhase("error");
423+
return false;
424+
}
414425
const data = encodeFunctionData({
415426
abi: depositPoolAbi,
416427
functionName: "claim",

src/lib/morpheus.ts

Lines changed: 134 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -93,34 +93,89 @@ export const WITHDRAW_LOCK_SECONDS = 604800;
9393

9494
// ---- ABIs (exact signatures from the Morpheus source, verified on-chain) ----
9595
export const depositPoolAbi = [
96-
{ type: "function", name: "stake", stateMutability: "nonpayable", inputs: [
97-
{ name: "rewardPoolIndex", type: "uint256" }, { name: "amount", type: "uint256" },
98-
{ name: "claimLockEnd", type: "uint128" }, { name: "referrer", type: "address" },
99-
], outputs: [] },
100-
{ type: "function", name: "withdraw", stateMutability: "nonpayable", inputs: [
101-
{ name: "rewardPoolIndex", type: "uint256" }, { name: "amount", type: "uint256" },
102-
], outputs: [] },
103-
{ type: "function", name: "claim", stateMutability: "payable", inputs: [
104-
{ name: "rewardPoolIndex", type: "uint256" }, { name: "receiver", type: "address" },
105-
], outputs: [] },
106-
{ type: "function", name: "claimFor", stateMutability: "payable", inputs: [
107-
{ name: "poolId", type: "uint256" }, { name: "user", type: "address" }, { name: "receiver", type: "address" },
108-
], outputs: [] },
109-
{ type: "function", name: "setClaimReceiver", stateMutability: "nonpayable", inputs: [
110-
{ name: "rewardPoolIndex", type: "uint256" }, { name: "receiver", type: "address" },
111-
], outputs: [] },
112-
{ type: "function", name: "getLatestUserReward", stateMutability: "view", inputs: [
113-
{ name: "rewardPoolIndex", type: "uint256" }, { name: "user", type: "address" },
114-
], outputs: [{ type: "uint256" }] },
115-
{ type: "function", name: "usersData", stateMutability: "view", inputs: [
116-
{ name: "user", type: "address" }, { name: "rewardPoolIndex", type: "uint256" },
117-
], outputs: [
118-
{ name: "lastStake", type: "uint128" }, { name: "deposited", type: "uint256" },
119-
{ name: "rate", type: "uint256" }, { name: "pendingRewards", type: "uint256" },
120-
{ name: "claimLockStart", type: "uint128" }, { name: "claimLockEnd", type: "uint128" },
121-
{ name: "virtualDeposited", type: "uint256" }, { name: "lastClaim", type: "uint128" },
122-
{ name: "referrer", type: "address" },
123-
] },
96+
{
97+
type: "function",
98+
name: "stake",
99+
stateMutability: "nonpayable",
100+
inputs: [
101+
{ name: "rewardPoolIndex", type: "uint256" },
102+
{ name: "amount", type: "uint256" },
103+
{ name: "claimLockEnd", type: "uint128" },
104+
{ name: "referrer", type: "address" },
105+
],
106+
outputs: [],
107+
},
108+
{
109+
type: "function",
110+
name: "withdraw",
111+
stateMutability: "nonpayable",
112+
inputs: [
113+
{ name: "rewardPoolIndex", type: "uint256" },
114+
{ name: "amount", type: "uint256" },
115+
],
116+
outputs: [],
117+
},
118+
{
119+
type: "function",
120+
name: "claim",
121+
stateMutability: "payable",
122+
inputs: [
123+
{ name: "rewardPoolIndex", type: "uint256" },
124+
{ name: "receiver", type: "address" },
125+
],
126+
outputs: [],
127+
},
128+
{
129+
type: "function",
130+
name: "claimFor",
131+
stateMutability: "payable",
132+
inputs: [
133+
{ name: "poolId", type: "uint256" },
134+
{ name: "user", type: "address" },
135+
{ name: "receiver", type: "address" },
136+
],
137+
outputs: [],
138+
},
139+
{
140+
type: "function",
141+
name: "setClaimReceiver",
142+
stateMutability: "nonpayable",
143+
inputs: [
144+
{ name: "rewardPoolIndex", type: "uint256" },
145+
{ name: "receiver", type: "address" },
146+
],
147+
outputs: [],
148+
},
149+
{
150+
type: "function",
151+
name: "getLatestUserReward",
152+
stateMutability: "view",
153+
inputs: [
154+
{ name: "rewardPoolIndex", type: "uint256" },
155+
{ name: "user", type: "address" },
156+
],
157+
outputs: [{ type: "uint256" }],
158+
},
159+
{
160+
type: "function",
161+
name: "usersData",
162+
stateMutability: "view",
163+
inputs: [
164+
{ name: "user", type: "address" },
165+
{ name: "rewardPoolIndex", type: "uint256" },
166+
],
167+
outputs: [
168+
{ name: "lastStake", type: "uint128" },
169+
{ name: "deposited", type: "uint256" },
170+
{ name: "rate", type: "uint256" },
171+
{ name: "pendingRewards", type: "uint256" },
172+
{ name: "claimLockStart", type: "uint128" },
173+
{ name: "claimLockEnd", type: "uint128" },
174+
{ name: "virtualDeposited", type: "uint256" },
175+
{ name: "lastClaim", type: "uint128" },
176+
{ name: "referrer", type: "address" },
177+
],
178+
},
124179
] as const;
125180

126181
// ---- Claim / LayerZero fee (all read on-chain from L1SenderV2's config) ----
@@ -132,16 +187,57 @@ export const depositPoolAbi = [
132187
export const L1_SENDER = getAddress("0x2Efd4430489e1a05A89c2f51811aC661B7E5FF84");
133188
export const LZ_GATEWAY = getAddress("0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675");
134189
export const LZ_DST_CHAIN_ID = 110; // LayerZero v1 chain id for Arbitrum One
135-
export const LZ_ADAPTER_PARAMS = "0x" as const;
136-
137-
export const lzEndpointAbi = [{
138-
type: "function", name: "estimateFees", stateMutability: "view",
139-
inputs: [
140-
{ name: "dstChainId", type: "uint16" }, { name: "userApplication", type: "address" },
141-
{ name: "payload", type: "bytes" }, { name: "payInZRO", type: "bool" }, { name: "adapterParam", type: "bytes" },
142-
],
143-
outputs: [{ name: "nativeFee", type: "uint256" }, { name: "zroFee", type: "uint256" }],
144-
}] as const;
190+
191+
/**
192+
* Type-1 adapter params: `encodePacked(uint16(1), uint256(destinationGas))`.
193+
*
194+
* This used to be `"0x"`, which on classic LayerZero V1 meant "use the
195+
* pathway's configured default". The V1 endpoint now routes through the
196+
* V2-era UltraLightNode, which converts adapter params into worker options
197+
* and REJECTS empty bytes — `estimateFees` reverts with
198+
* `LZ_ULN_InvalidWorkerOptions(uint256)` (0x6592671c), which is exactly how
199+
* every MOR claim died: the fee quote threw before any transaction was sent.
200+
* Reproduced both ways by direct RPC call: `0x` reverts; type-1 params quote
201+
* fine (200k → 0.0000481 ETH, 350k → 0.0000511 ETH).
202+
*
203+
* 350k destination gas is deliberately conservative, NOT a documented
204+
* Morpheus figure: this constant only shapes the QUOTE (the value attached
205+
* to `claim()`), the send's real destination gas comes from the L1Sender's
206+
* own stored config, and the contracts refund excess `msg.value`. Over-quote
207+
* costs ~$0.01 refunded; under-quote kills the claim.
208+
*/
209+
export const LZ_ADAPTER_PARAMS = `0x0001${BigInt(350_000).toString(16).padStart(64, "0")}` as const;
210+
211+
export const lzEndpointAbi = [
212+
{
213+
type: "function",
214+
name: "estimateFees",
215+
stateMutability: "view",
216+
inputs: [
217+
{ name: "dstChainId", type: "uint16" },
218+
{ name: "userApplication", type: "address" },
219+
{ name: "payload", type: "bytes" },
220+
{ name: "payInZRO", type: "bool" },
221+
{ name: "adapterParam", type: "bytes" },
222+
],
223+
outputs: [
224+
{ name: "nativeFee", type: "uint256" },
225+
{ name: "zroFee", type: "uint256" },
226+
],
227+
},
228+
// The ULN's revert vocabulary, so viem can decode a failure into a NAME
229+
// instead of showing users a raw selector with an openchain link.
230+
{
231+
type: "error",
232+
name: "LZ_ULN_InvalidWorkerOptions",
233+
inputs: [{ name: "cursor", type: "uint256" }],
234+
},
235+
{
236+
type: "error",
237+
name: "LZ_ULN_UnsupportedOptionType",
238+
inputs: [{ name: "optionType", type: "uint256" }],
239+
},
240+
] as const;
145241

146242
/** MOR reward destination for a rider — a 2-recipient Arbitrum split (Gnars/athlete). */
147243
export type MorpheusSponsor = { morSplit?: Address };

0 commit comments

Comments
 (0)