feat: enhance Earn feature with EVM support and LayerSwap integration - #663
Conversation
- Added configuration options for EVM Earn and LayerSwap API in .env.example and config.ts. - Introduced new components and hooks to manage EVM Earn flows, including EarnBridgeTracker and useEvmEarnHandler. - Updated existing components (e.g., EarnActivityPanel, EarnWalletForm) to support EVM-specific logic and display. - Implemented filtering for earn activity based on the source chain, allowing for better user experience across different networks. - Enhanced UI elements to indicate when Earn is unavailable on certain networks, improving clarity for users. - Refactored related types and utility functions to accommodate new EVM features and ensure type safety.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
🚧 Files skipped from review as they are similar to previous changes (7)
📝 WalkthroughWalkthroughAdds EVM-to-Starknet Earn through LayerSwap and Vesu. The change adds chain configuration, API routes, bridge execution, persistent recovery, feature gating, activity filtering, and network-aware wallet interfaces. ChangesEVM-to-Starknet Earn
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (6)
__tests__/layerswapStarknetExecute.test.ts (1)
3-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover the throwing branches.
layerswapDepositActionsToStarknetCallsgates a transaction-signing path, and every failure mode throws. Add cases for invalid JSON, a call that omitsentrypoint, acall_datavalue of"0x", and multiple actions with out-of-orderordervalues.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@__tests__/layerswapStarknetExecute.test.ts` around lines 3 - 26, Extend the layerswapDepositActionsToStarknetCalls tests with cases asserting throws for invalid JSON, missing entrypoint, and call_data equal to "0x". Add a multiple-action case with out-of-order order values and assert the resulting calls follow the expected order.app/lib/layerswap.ts (2)
76-99: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winPreserve the upstream HTTP status instead of discarding it.
validateStatus: () => truemakes axios resolve for 401, 429, and 5xx responses. The code then only readsdata.error?.message. If LayerSwap returns an error body in another shape, or an HTML gateway page,data.erroris undefined and the helper throws "LayerSwap quote unavailable". An expired API key and a rate limit then look identical to a missing quote, and every caller maps the result to 502.Include the status in the thrown error, or drop
validateStatusand handleAxiosError.response.status. The same option appears at lines 133, 182, and 204.♻️ Proposed status propagation
- const { data } = await axios.get<LayerswapApiResponse<LayerswapQuote>>( + const { data, status } = await axios.get<LayerswapApiResponse<LayerswapQuote>>( `${LAYERSWAP_API_BASE}/api/v2/quote`, @@ if (data.error?.message) { throw new Error(data.error.message); } if (!data.data) { - throw new Error("LayerSwap quote unavailable"); + throw new Error(`LayerSwap quote unavailable (upstream status ${status})`); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/lib/layerswap.ts` around lines 76 - 99, Update the LayerSwap request error handling around the quote-fetching method and the other requests using validateStatus: () => true to preserve and propagate the upstream HTTP status. Include the response status when throwing for error responses, including bodies without data.error or non-JSON gateway responses, so callers can distinguish authentication, rate-limit, server, and unavailable-quote failures instead of mapping them all to 502.
261-270: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winValidate
calldataelement types.
c.calldatais accepted without a type check. A payload such as{"contractAddress":"0x1","entrypoint":"transfer","calldata":[{"a":1}]}passes this loop and fails later inside the Starknet account, which produces an opaque error at transaction build time.Reject non-string entries here.
♻️ Proposed shape check
if (!c.contractAddress || !c.entrypoint) { throw new Error("LayerSwap Starknet call is missing fields"); } + const calldata = c.calldata ?? []; + if ( + !Array.isArray(calldata) || + calldata.some((v) => typeof v !== "string") + ) { + throw new Error("LayerSwap Starknet call has invalid calldata"); + } calls.push({ contractAddress: c.contractAddress, entrypoint: c.entrypoint, - calldata: c.calldata ?? [], + calldata, });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/lib/layerswap.ts` around lines 261 - 270, Update the validation in the loop processing parsed LayerSwap calls, alongside the existing contractAddress and entrypoint checks, to require that every c.calldata element is a string. Reject any non-string calldata entry with the same early validation error before pushing the call, while preserving the existing default of [] when calldata is absent.app/providers.tsx (1)
127-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGate the tracker on the feature flag.
EarnBridgeTrackermounts on every page and installs a 15-second interval. It runs even whenconfig.evmEarnEnabledis false, so a disabled feature can still resume a persisted bridge job and call the Earn deposit path. Mount it only when the flag is on.♻️ Proposed gating
- <EarnBridgeTracker /> + {config.evmEarnEnabled && <EarnBridgeTracker />}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/providers.tsx` at line 127, Update the provider rendering around EarnBridgeTracker so it mounts only when config.evmEarnEnabled is true. Preserve the existing tracker behavior and placement when the feature flag is enabled, and render nothing for the disabled case.app/hooks/useEarnSourcePosition.ts (1)
19-21: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winDrop the localStorage read from the state initializer.
The server render produces
nullbecausereadEarnSourcePositionguards ontypeof window. The first client render calls this initializer and can return a stored position.EarnSourcePositionCardthen renders a card where the server rendered nothing, which causes a hydration mismatch. The mount effect at line 31 already hydrates the value.♻️ Proposed fix
- const [position, setPosition] = useState<EarnSourcePosition | null>(() => - evmAddress ? readEarnSourcePosition(evmAddress, sourceChain, token) : null, - ); + const [position, setPosition] = useState<EarnSourcePosition | null>(null);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/hooks/useEarnSourcePosition.ts` around lines 19 - 21, Remove the localStorage-backed read from the useState initializer in useEarnSourcePosition, initializing position to null unconditionally. Keep the existing mount effect responsible for calling readEarnSourcePosition and hydrating the stored value after client mount.app/components/EarnWalletForm.tsx (1)
188-206: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winClear the stale quote when the amount becomes invalid.
Both effects return before calling the fetchers when
amountStringis empty or non-positive.quoteandwithdrawQuotekeep the previous values. The blocks at lines 554 and 534 render wheneveramountEnteredis true, so the receive estimate and the projection at line 211 can describe an earlier amount.Call the fetcher with
0in the early-return branch.fetchQuoteandfetchWithdrawQuotealready reset their state for non-positive input.♻️ Proposed fix
useEffect(() => { - if (!isEvmFlow || tab !== "deposit" || !amountString) return; + if (!isEvmFlow || tab !== "deposit") return; const human = parseFloat(amountString); - if (!(human > 0)) return; + if (!(human > 0)) { + void fetchQuote(0); + return; + } const id = window.setTimeout(() => { void fetchQuote(human); }, 400); return () => clearTimeout(id); }, [amountString, fetchQuote, isEvmFlow, tab]); useEffect(() => { - if (!isEvmFlow || tab !== "withdraw" || !amountString) return; + if (!isEvmFlow || tab !== "withdraw") return; const human = parseFloat(amountString); - if (!(human > 0)) return; + if (!(human > 0)) { + void fetchWithdrawQuote(0); + return; + } const id = window.setTimeout(() => { void fetchWithdrawQuote(human); }, 400); return () => clearTimeout(id); }, [amountString, fetchWithdrawQuote, isEvmFlow, tab]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/components/EarnWalletForm.tsx` around lines 188 - 206, Update both effects around fetchQuote and fetchWithdrawQuote so empty or non-positive amountString values invoke the corresponding fetcher with 0 before returning, clearing stale quote state. Preserve the existing positive-amount debounce behavior and dependency arrays.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.env.example:
- Around line 100-103: Validate LAYERSWAP_API_BASE_URL before any authenticated
LayerSwap requests, allowing only URLs whose protocol is https: and rejecting
all other protocols. Apply this validation in the LayerSwap
configuration/request flow that consumes LAYERSWAP_API_BASE_URL, while
preserving the existing default URL behavior.
In `@app/api/earn/layerswap/quote/route.ts`:
- Around line 16-24: Replace parseFloat with Number in both layerswap quote
routes, and validate amount with Number.isFinite(amount) && amount > 0 before
calling layerswapGetQuote; preserve the existing 400 response for invalid input.
Update app/api/earn/layerswap/quote/route.ts (lines 16-24) and
app/api/earn/layerswap/withdraw-quote/route.ts (lines 20-31) with HTTP 400 tests
covering partially parsed values such as "1abc" and non-finite values such as
"Infinity".
In `@app/api/earn/layerswap/starknet-deposit/route.ts`:
- Around line 114-120: Update the receipt wait in the Starknet deposit route
around account.waitForTransaction to use the supported retry or timeout option
so a stuck transaction cannot block indefinitely. In the txReceipt.isSuccess()
failure response, preserve the existing error and status while also returning
result.transaction_hash for client reconciliation.
In `@app/api/earn/layerswap/swap/route.ts`:
- Around line 17-30: Move request.json() parsing inside a try block in
app/api/earn/layerswap/swap/route.ts lines 17-30, catching malformed JSON and
returning HTTP 400 with { error: "Invalid JSON body" } before destructuring.
Apply the same guard to app/api/earn/layerswap/withdraw-swap/route.ts lines
17-30 before destructuring its withdrawal fields.
- Around line 8-15: Require Privy authentication and resource authorization
across all LayerSwap proxy routes: in app/api/earn/layerswap/swap/route.ts,
authenticate before creating a swap and verify ownership of sourceAddress; in
app/api/earn/layerswap/withdraw-swap/route.ts, authenticate and verify ownership
of the supplied Starknet sourceAddress; in
app/api/earn/layerswap/swap/status/route.ts, authenticate, authorize access to
the requested swap, and return only its status and receive amount.
In `@app/hooks/useEvmEarnHandler.ts`:
- Around line 227-237: Source-position writes overwrite existing balances
instead of accumulating deposits. Add a shared helper such as
addEarnSourcePosition in app/lib/earnPositionStore.ts that reads the current
position, adds deltaBaseUnits, and writes matching base-unit and formatted
totals; update app/hooks/useEvmEarnHandler.ts lines 227-237 and
app/hooks/useEarnBridgeStatusTracker.ts lines 137-157 to use it for their
respective address/source-chain/USDC values.
- Around line 197-225: Update the live bridge flow around executeBatchCalls and
PendingEarnBridgeJob to persist the job before submitting the bridge
transaction, with claimedByLiveFlow set so useEarnBridgeStatusTracker skips it
while the live flow owns recovery. Add the optional claimedByLiveFlow field in
PendingEarnBridgeJob, make the tracker ignore claimed jobs, and clear the claim
when the live flow aborts so recovery remains available. Keep vesuDeposit as the
sole deposit path for the claimed live flow.
- Around line 333-336: Update the withdrawal flow in useEvmEarnHandler around
withdrawToEvm so it no longer calls clearEarnSourcePosition unconditionally
after pollSwapUntilComplete. Use readEarnSourcePosition from earnPositionStore
to load the current USDC position, subtract the withdrawn amountBaseUnits, and
persist the reduced balance instead of deleting the record. Only call
clearEarnSourcePosition when the remaining balance reaches zero, then keep the
existing refreshPosition("USDC") path.
- Around line 165-175: Update both LayerSwap swap-creation POST requests in
useEvmEarnHandler to include the authenticated bearer token in their
Authorization headers. In the corresponding route handlers, validate that bearer
token before accessing or using the server-side LayerSwap API key, preserving
the existing request behavior only after authentication succeeds.
In `@app/lib/config.ts`:
- Around line 87-88: Update the layerswapApiBaseUrl configuration normalization
to remove all trailing slashes from the selected environment value or default,
then validate that the resulting URL uses https:// and reject non-HTTPS values
before requests are constructed. Preserve the existing default while ensuring
downstream /api/v2/... paths do not produce duplicate slashes.
In `@app/lib/layerswapExecute.ts`:
- Around line 109-117: Update the call construction in the action loop so ERC-20
deposits always use native value 0, while actions without a token contract may
attach their parsed native amount. Reuse the guarded amount-parsing behavior
from parseActionTokenAmount for tokenless actions, and avoid converting
amount_in_base_units for actions that carry a token contract.
---
Nitpick comments:
In `@__tests__/layerswapStarknetExecute.test.ts`:
- Around line 3-26: Extend the layerswapDepositActionsToStarknetCalls tests with
cases asserting throws for invalid JSON, missing entrypoint, and call_data equal
to "0x". Add a multiple-action case with out-of-order order values and assert
the resulting calls follow the expected order.
In `@app/components/EarnWalletForm.tsx`:
- Around line 188-206: Update both effects around fetchQuote and
fetchWithdrawQuote so empty or non-positive amountString values invoke the
corresponding fetcher with 0 before returning, clearing stale quote state.
Preserve the existing positive-amount debounce behavior and dependency arrays.
In `@app/hooks/useEarnSourcePosition.ts`:
- Around line 19-21: Remove the localStorage-backed read from the useState
initializer in useEarnSourcePosition, initializing position to null
unconditionally. Keep the existing mount effect responsible for calling
readEarnSourcePosition and hydrating the stored value after client mount.
In `@app/lib/layerswap.ts`:
- Around line 76-99: Update the LayerSwap request error handling around the
quote-fetching method and the other requests using validateStatus: () => true to
preserve and propagate the upstream HTTP status. Include the response status
when throwing for error responses, including bodies without data.error or
non-JSON gateway responses, so callers can distinguish authentication,
rate-limit, server, and unavailable-quote failures instead of mapping them all
to 502.
- Around line 261-270: Update the validation in the loop processing parsed
LayerSwap calls, alongside the existing contractAddress and entrypoint checks,
to require that every c.calldata element is a string. Reject any non-string
calldata entry with the same early validation error before pushing the call,
while preserving the existing default of [] when calldata is absent.
In `@app/providers.tsx`:
- Line 127: Update the provider rendering around EarnBridgeTracker so it mounts
only when config.evmEarnEnabled is true. Preserve the existing tracker behavior
and placement when the feature flag is enabled, and render nothing for the
disabled case.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 335bc68f-25ae-4c6e-894f-23d2b98160ba
📒 Files selected for processing (31)
.env.example__tests__/earnChains.test.ts__tests__/earnFeature.test.ts__tests__/layerswapStarknetExecute.test.tsapp/api/earn/layerswap/quote/route.tsapp/api/earn/layerswap/starknet-deposit/route.tsapp/api/earn/layerswap/swap/route.tsapp/api/earn/layerswap/swap/status/route.tsapp/api/earn/layerswap/withdraw-quote/route.tsapp/api/earn/layerswap/withdraw-swap/route.tsapp/components/EarnActivityPanel.tsxapp/components/EarnBridgeTracker.tsxapp/components/EarnSourcePositionCard.tsxapp/components/EarnUnavailableModal.tsxapp/components/EarnWalletForm.tsxapp/components/MobileDropdown.tsxapp/components/WalletDetails.tsxapp/components/wallet-mobile-modal/EarnHubView.tsxapp/components/wallet-mobile-modal/WalletView.tsxapp/hooks/useEarnBridgeStatusTracker.tsapp/hooks/useEarnHandler.tsapp/hooks/useEarnSourcePosition.tsapp/hooks/useEvmEarnHandler.tsapp/lib/config.tsapp/lib/earnChains.tsapp/lib/earnFeature.tsapp/lib/earnPositionStore.tsapp/lib/layerswap.tsapp/lib/layerswapExecute.tsapp/providers.tsxapp/types.ts
- Updated .env.example to include optional LayerSwap API base URL configuration. - Refactored LayerSwap API routes to improve error handling, including timeout responses for transaction confirmations. - Introduced validation functions for parsing amounts in LayerSwap requests, ensuring robust input handling. - Enhanced the Earn bridge status tracking to include wallet ID in API calls, improving user-specific data retrieval. - Updated hooks and utility functions to support new validation and error handling logic, ensuring a smoother user experience.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/lib/earnPositionStore.ts`:
- Around line 193-194: Update the formatting logic for suppliedFormatted at both
affected locations in the earn position update flow to avoid converting bigint
values through Number. Format the USDC base-unit bigint directly by separating
whole units and the six-digit fractional remainder, preserving exact values
above Number.MAX_SAFE_INTEGER while retaining six decimal places.
- Around line 24-35: Update the live-flow claim handling around depositFromEvm
and isStaleLiveFlowClaim to record a claimedAt timestamp when claimedByLiveFlow
is set, and base stale detection on claimedAt when present, falling back to
createdAt for legacy jobs. Add a regression test covering an old createdAt with
a recent claimedAt to ensure the claim is not treated as stale.
In `@app/lib/layerswapRouteAuth.ts`:
- Around line 63-65: Update normalizeStarknetAddress to use the existing
validating, felt-aware Starknet address normalizer before lowercasing, so padded
and unpadded representations canonicalize identically. Ensure both comparison
operands in swapBelongsToUser and assertStarknetAddressOwnedByUser pass through
this shared normalization path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 049c4e4e-4765-4809-b653-d3945da65156
📒 Files selected for processing (19)
.env.example__tests__/earnPositionStore.test.ts__tests__/layerswapExecute.test.ts__tests__/layerswapValidation.test.tsapp/api/earn/layerswap/quote/route.tsapp/api/earn/layerswap/starknet-deposit/route.tsapp/api/earn/layerswap/swap/route.tsapp/api/earn/layerswap/swap/status/route.tsapp/api/earn/layerswap/withdraw-quote/route.tsapp/api/earn/layerswap/withdraw-swap/route.tsapp/hooks/useEarnBridgeStatusTracker.tsapp/hooks/useEvmEarnHandler.tsapp/lib/config.tsapp/lib/earnPositionStore.tsapp/lib/layerswap.tsapp/lib/layerswapConfig.tsapp/lib/layerswapExecute.tsapp/lib/layerswapRouteAuth.tsapp/lib/layerswapValidation.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- app/api/earn/layerswap/withdraw-quote/route.ts
- .env.example
- app/hooks/useEvmEarnHandler.ts
- app/api/earn/layerswap/starknet-deposit/route.ts
- app/lib/layerswap.ts
- app/hooks/useEarnBridgeStatusTracker.ts
Description
Adds Phase 2 Earn: users on supported EVM chains (Base, Polygon, Arbitrum, etc.) can deposit USDC via LayerSwap into Vesu on Starknet, view their position on the source chain only, and withdraw back to that same chain — without holding ETH or STRK for gas.
Background: Phase 1 Earn (Starknet-native Vesu deposit/withdraw) already existed. This PR extends Earn to EVM source chains by bridging through LayerSwap while keeping the UX gasless and chain-scoped per product user stories #3–#5.
What changed:
receive_amount(post-bridge-fee)./api/earn/layerswap/starknet-deposit) → poll until funds arrive on source chain; clears chain-scoped local position.sourceChain(earnPositionStore,useEarnSourcePosition,filterEarnActivityForChain). EVM wallet views no longer show the global Starknet Vesu balance on unrelated chains.layerswap.ts; six Next.js API routes under/api/earn/layerswap/*(quote, swap, status, withdraw-quote, withdraw-swap, starknet-deposit); config keyslayerswapApiKey/layerswapApiBaseUrlinconfig.ts.eth_sendTransactionwithexecuteBatchCallsso ERC-20 approve calldata is not corrupted bydataSuffix.requestedAmountBaseUnitsandreceiveAmountBaseUnitsso resume/deposit uses the bridged amount, not the source send amount.Feature flags / env (off by default):
NEXT_PUBLIC_EVM_EARN_ENABLED=trueLAYERSWAP_API_KEY(server-side)LAYERSWAP_API_BASE_URL(optional; defaults tohttps://api.layerswap.io)Breaking changes: None when flags remain
false. Starknet-native Earn behavior unchanged.Alternatives considered:
/api/starknet/earn/*pattern.API / contracts: New internal Noblocks routes only; no aggregator or on-chain contract changes. LayerSwap fees apply on bridge (especially on small amounts).
References
https://paycrest-io.atlassian.net/jira/software/projects/KAN/boards/3?selectedIssue=KAN-394
Testing
Unit tests added/updated:
__tests__/earnFeature.test.ts— feature flags, chain-scoped activity filter (incl. legacy untagged deposits)__tests__/earnChains.test.ts— supported EVM source chains / LayerSwap network map__tests__/layerswapStarknetExecute.test.ts— LayerSwap Starknetdeposit_actions→ StarknetCall[]parsingManual E2E (Base, recommended):
NEXT_PUBLIC_EARN_ENABLED=true,NEXT_PUBLIC_EVM_EARN_ENABLED=true,LAYERSWAP_API_KEY, paymaster + bundler keys.Checklist
.env.exampleentries + unit tests)mainBy submitting a PR, I agree to Paycrest's Contributor Code of Conduct and Contribution Guide.
Summary by CodeRabbit