Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e43179c
fix(card): keep unified balance steady during collateral top-up
Hugo0 Jun 2, 2026
fe493d4
doc: flag display-vs-gate split on formattedSpendableBalance
Hugo0 Jun 2, 2026
a8f5959
refactor: rename wei-misnomer USDC helpers
Hugo0 Jun 2, 2026
695dc6a
Merge remote-tracking branch 'origin/main' into hotfix/rain-card-bala…
Hugo0 Jun 2, 2026
80f45cb
fix(seo): wire spei + faster-payments deposit rails; green the conten…
Hugo0 Jun 2, 2026
4e949ea
Merge pull request #2178 from peanutprotocol/dev
Hugo0 Jun 3, 2026
3561b16
chore(content): bump submodule for card-legal formatting fix (#2180)
Hugo0 Jun 3, 2026
ccdbae9
Merge remote-tracking branch 'origin/main' into hotfix/rain-card-bala…
jjramirezn Jun 3, 2026
0c75031
fix(seo): keep spei + faster-payments as deposit exchanges (from-), m…
jjramirezn Jun 3, 2026
b289b63
Merge pull request #2170 from peanutprotocol/hotfix/rain-card-balance…
jjramirezn Jun 3, 2026
2372fa9
chore(content): bump submodule — non-custodial collateral language fix
Hugo0 Jun 3, 2026
deb3ea0
Merge pull request #2183 from peanutprotocol/chore/card-legal-noncust…
Hugo0 Jun 3, 2026
274439e
fix(card-apply): poll cheap readiness endpoint instead of POST /rain/…
jjramirezn Jun 3, 2026
af22841
feat(card): /card-recovery page for deleted-Rain-user collateral reco…
jjramirezn Jun 4, 2026
ca92af8
Merge pull request #2187 from peanutprotocol/fix/rain-card-recover-funds
Hugo0 Jun 4, 2026
2393e9e
Merge pull request #2185 from peanutprotocol/fix/rain-card-readiness-…
Hugo0 Jun 4, 2026
52246c0
fix(kernel): reject a restored passkey that belongs to a different ac…
jjramirezn Jun 4, 2026
8d850fb
Merge pull request #2188 from peanutprotocol/hotfix/passkey-wrong-cre…
Hugo0 Jun 4, 2026
b878cb3
Merge pull request #2192 from peanutprotocol/dev
Hugo0 Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .verify-content-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
726
745
215 changes: 215 additions & 0 deletions src/app/(mobile-ui)/card-recovery/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import type { Address, Hex } from 'viem'
import { Button } from '@/components/0_Bruddle/Button'
import { Card } from '@/components/0_Bruddle/Card'
import ErrorAlert from '@/components/Global/ErrorAlert'
import NavHeader from '@/components/Global/NavHeader'
import PeanutLoading from '@/components/Global/PeanutLoading'
import { useKernelClient } from '@/context/kernelClient.context'
import {
RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
rainWithdrawEip712Types,
} from '@/constants/rain.consts'
import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts'
import { rainApi, type RecoverFundsPreviewResponse } from '@/services/rain'
import { getExplorerUrl } from '@/utils/general.utils'

type Step = 'preview' | 'confirm' | 'signing' | 'submitting' | 'done'

/**
* Card collateral recovery flow.
*
* For the deleted-Rain-user case: a user's collateral USDC is sitting on-chain
* in the Rain coordinator's proxy, but Rain's balance endpoint won't return it
* because their Rain user record was deleted. The normal /withdraw flow can't
* see the balance, so we have a dedicated recovery endpoint pair on the
* backend that reads the on-chain balance directly and asks Rain for a
* signature for that exact amount, paid to the user's own smart wallet.
*
* This page wires that flow: preview → confirm → kernel-sign EIP-712 →
* submit. The destination address is decided by the backend and shown here
* for transparency; the FE cannot influence it.
*
* Not linked from anywhere in the main app — accessed by URL only. It's safe
* to share the URL with a user who needs to recover funds: the JWT cookie is
* the only auth, the recipient is server-locked, and the signing step still
* requires the user's passkey.
*/
export default function CardRecoveryPage() {
const router = useRouter()
const { getClientForChain } = useKernelClient()

const [step, setStep] = useState<Step>('preview')
const [preview, setPreview] = useState<RecoverFundsPreviewResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [txHash, setTxHash] = useState<Hex | null>(null)

useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await rainApi.getRecoverFundsPreview()
if (!cancelled) setPreview(data)
} catch (e) {
if (!cancelled) setError((e as Error).message || 'Could not load recovery preview')
}
})()
return () => {
cancelled = true
}
}, [])

const handleRecover = useCallback(async () => {
setError(null)
setStep('signing')
try {
// Prepare locks in the amount + recipient server-side. Even if the
// page were tampered with at runtime, the backend signs over the
// values it computed itself.
const prep = await rainApi.prepareRecoverFunds()

const chainIdStr = String(PEANUT_WALLET_CHAIN.id)
const chainIdNum = Number(prep.chainId)
const kernelClient = getClientForChain(chainIdStr)

const adminSignature = (await kernelClient.account!.signTypedData({
domain: {
name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
chainId: chainIdNum,
verifyingContract: prep.collateralProxy as Address,
salt: prep.adminSalt as Hex,
},
types: rainWithdrawEip712Types,
primaryType: 'Withdraw',
message: {
user: prep.adminAddress as Address,
asset: prep.tokenAddress as Address,
amount: BigInt(prep.amount),
recipient: prep.recipientAddress as Address,
nonce: BigInt(prep.adminNonce),
},
})) as Hex

setStep('submitting')
const { txHash: hash } = await rainApi.submitWithdrawal({
preparationId: prep.preparationId,
amount: prep.amount,
recipientAddress: prep.recipientAddress,
directTransfer: prep.directTransfer,
adminSalt: prep.adminSalt,
adminNonce: prep.adminNonce,
adminSignature,
executorSignature: prep.executorSignature,
executorSalt: prep.executorSalt,
expiresAt: prep.expiresAt,
})
setTxHash(hash as Hex)
setStep('done')
} catch (e) {
setError((e as Error).message || 'Recovery failed — please try again')
setStep('preview')
}
}, [getClientForChain])

if (!preview && !error) return <PeanutLoading />

return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Recover card funds" onPrev={() => router.push('/home')} />

Check failure on line 123 in src/app/(mobile-ui)/card-recovery/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

Bare router.push/replace as onPrev/onBack creates a parent↔child cycle once the parent uses useSafeBack (the push grows in-app history, useSafeBack pops back to this screen, repeat). Use useSafeBack(parentUrl) — pass { replace: true } to preserve replace semantics. See PR #1997

Check failure on line 123 in src/app/(mobile-ui)/card-recovery/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

Bare router.push/replace as onPrev/onBack creates a parent↔child cycle once the parent uses useSafeBack (the push grows in-app history, useSafeBack pops back to this screen, repeat). Use useSafeBack(parentUrl) — pass { replace: true } to preserve replace semantics. See PR #1997

Check failure on line 123 in src/app/(mobile-ui)/card-recovery/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

Bare router.push/replace as onPrev/onBack creates a parent↔child cycle once the parent uses useSafeBack (the push grows in-app history, useSafeBack pops back to this screen, repeat). Use useSafeBack(parentUrl) — pass { replace: true } to preserve replace semantics. See PR #1997
<div className="my-auto flex flex-col gap-6">
{error && <ErrorAlert description={error} />}

{step === 'done' && txHash ? (
<Card className="flex flex-col gap-3 p-6">
<h2 className="text-h7 font-bold">Funds sent to your wallet.</h2>
<p className="text-sm text-grey-1">
${formatCents(preview!.amountCents)} USDC has been returned to your peanut wallet.
</p>
<a
className="text-black underline"
target="_blank"
rel="noreferrer"
href={`${getExplorerUrl(String(PEANUT_WALLET_CHAIN.id)) ?? ''}/tx/${txHash}`}
>
View transaction
</a>
</Card>
) : (
preview && (
<>
<Card className="flex flex-col gap-3 p-6">
<h2 className="text-h7 font-bold">
{preview.hasRecoverableCard ? 'Recover your card collateral' : 'No card on file'}
</h2>
<p className="text-sm text-grey-1">
This pulls every USDC currently held in your card collateral contract back to your
peanut wallet. Auto-balance is turned off as part of recovery so the rebalancer
can't top up between now and the transfer.
</p>

<Row label="Recoverable" value={`$${formatCents(preview.amountCents)} USDC`} />
<Row label="Destination" value={shorten(preview.recipient)} />
{BigInt(preview.dustWei) > 0n && (
<Row label="Dust left in contract" value={`${preview.dustWei} wei (< $0.01)`} />
)}
<Row
label="Auto-balance"
value={preview.autoBalanceEnabled ? 'on — will be turned off' : 'off'}
/>
</Card>

<Button
variant="purple"
shadowSize="4"
className="w-full"
disabled={
step === 'signing' ||
step === 'submitting' ||
BigInt(preview.amountCents) <= 0n ||
!preview.hasRecoverableCard
}
loading={step === 'signing' || step === 'submitting'}
onClick={handleRecover}
>
{step === 'signing'
? 'Sign with passkey…'
: step === 'submitting'
? 'Submitting…'
: 'Recover funds'}
</Button>
</>
)
)}
</div>
</div>
)
}

function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-grey-1">{label}</span>
<span className="text-sm font-medium text-n-1">{value}</span>
</div>
)
}

// Render Rain cents (2 dp) as a fixed-precision USD amount with thousand
// separators. Cents are bigint-string from the wire — never Number() them
// directly; > 2^53 risks lossy display on whales.
function formatCents(centsStr: string): string {
const cents = BigInt(centsStr)
const dollars = cents / 100n
const remainder = (cents % 100n).toString().padStart(2, '0')
return `${dollars.toLocaleString('en-US')}.${remainder}`
}

function shorten(addr: string): string {
if (addr.length <= 12) return addr
return `${addr.slice(0, 6)}…${addr.slice(-4)}`
}
30 changes: 28 additions & 2 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { cardApi, type CardInfoResponse } from '@/services/card'
import { useAuth } from '@/context/authContext'
import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview'
import { computeCardState, findActiveCard, type CardTopLevelState } from '@/components/Card/cardState.utils'
import { pollUntilApplyAdvances } from '@/components/Card/cardApply.utils'
import { pollUntilApplyAdvances, pollUntilReady } from '@/components/Card/cardApply.utils'
import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen'
import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen'
import CardTermsScreen from '@/components/Card/CardTermsScreen'
Expand Down Expand Up @@ -360,10 +360,36 @@ const CardPage: FC = () => {
pollAbortRef.current = controller

try {
// Two-stage poll. First wait for the webhook-stamped readiness flag
// (cheap, DB-only, safe at 1s cadence). Once Sumsub has reviewed
// rain-requirements GREEN, fall through to the existing apply poll.
//
// Previous behaviour: single-stage `pollUntilApplyAdvances` against
// POST /rain/cards — every iteration hit Sumsub for `moveToLevel` +
// `getApplicant` + `getQuestionnaireAnswers`. ~75 Sumsub round-trips
// per stuck user, AND the WebSDK got re-opened on every `incomplete`
// in the race window, showing the user "verification is taking
// longer than expected" (Barbara F-M's 2026-06-02 Crisp escalation).
const readyResult = await pollUntilReady({
fetchReadiness: () => rainApi.getCardApplyReadiness(),
intervalMs: 1000,
timeoutMs: 30000,
signal: controller.signal,
})
if (controller.signal.aborted) return
if (readyResult === false) {
setApplyError('Verification is taking longer than expected. Please try again.')
return
}

// Sumsub is GREEN — single apply call should now advance past
// `incomplete`. Keep `pollUntilApplyAdvances` as a thin safety net
// for the rare case where the webhook flag landed but the
// applicant state hasn't fully propagated (e.g. read-replica lag).
const res = await pollUntilApplyAdvances({
fetchApply: () => rainApi.applyForCard({ termsAccepted: false }),
intervalMs: 1000,
timeoutMs: 15000,
timeoutMs: 5000,
signal: controller.signal,
})
if (controller.signal.aborted) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
default: (props: any) => {
// next/image uses 'fill' boolean; strip non-DOM props
const { priority, layout, objectFit, fill, ...rest } = props
return <img {...rest} />

Check warning on line 51 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 51 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 51 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
},
}))

Expand Down Expand Up @@ -129,7 +129,7 @@
}))

jest.mock('@/utils/balance.utils', () => ({
rainSpendingPowerToWei: jest.fn(() => 0n),
rainCentsToUsdcUnits: jest.fn(() => 0n),
}))

const mockUseTransactionDetailsDrawer = jest.fn()
Expand Down
4 changes: 2 additions & 2 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import { rainSpendingPowerToWei } from '@/utils/balance.utils'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'
import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils'
import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils'
import {
Expand Down Expand Up @@ -229,7 +229,7 @@
if (sumsubFlow.showWrapper || sumsubFlow.isModalOpen) {
sumsubFlow.completeFlow()
}
}, [kycGateState, sumsubFlow.showWrapper, sumsubFlow.isModalOpen, sumsubFlow.completeFlow])

Check warning on line 232 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

Check warning on line 232 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

Check warning on line 232 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

const queryClient = useQueryClient()
const [isShaking, setIsShaking] = useState(false)
Expand Down Expand Up @@ -342,7 +342,7 @@
if (isSuccess || !!errorMessage) {
setLoadingState('Idle')
}
}, [isSuccess, errorMessage])

Check warning on line 345 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

Check warning on line 345 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

Check warning on line 345 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

// First fetch for qrcode info — only after KYC gating allows proceeding
useEffect(() => {
Expand All @@ -354,7 +354,7 @@
}

setIsFirstLoad(false)
}, [timestamp, paymentProcessor, qrCode])

Check warning on line 357 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

Check warning on line 357 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

Check warning on line 357 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

// Get amount from payment lock (Manteca)
useEffect(() => {
Expand All @@ -369,7 +369,7 @@
setAmount(paymentLock.paymentAgainstAmount)
setCurrencyAmount(paymentLock.paymentAssetAmount)
}
}, [paymentLock?.code, paymentProcessor])

Check warning on line 372 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 372 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 372 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Get currency object from payment lock (Manteca)
useEffect(() => {
Expand All @@ -391,7 +391,7 @@
}
}
getCurrencyObject().then(setCurrency)
}, [paymentLock?.code, paymentProcessor])

Check warning on line 394 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 394 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 394 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

const isBlockingError = useMemo(() => {
return !!errorMessage && errorMessage !== 'Please confirm the transaction.'
Expand All @@ -407,7 +407,7 @@
// For dynamic QR codes, backend provides the USD amount
return paymentLock.paymentAgainstAmount
}
}, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])

Check warning on line 410 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 410 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 410 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Live card-vs-local-rail markup, driven by Manteca's rate + (for ARS)
// BCRA's official rate. Used by both the confirm-screen "Save vs card"
Expand Down Expand Up @@ -600,7 +600,7 @@
requiredUsdcAmount,
recipient: MANTECA_DEPOSIT_ADDRESS,
smartBalance: balance ?? 0n,
rainSpendingPower: rainSpendingPowerToWei(rainCardOverview?.balance?.spendingPower),
rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'QR_PAY',
})
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils'
import { rainSpendingPowerToWei } from '@/utils/balance.utils'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
Expand Down Expand Up @@ -341,7 +341,7 @@ export default function MantecaWithdrawFlow() {
requiredUsdcAmount,
recipient: MANTECA_DEPOSIT_ADDRESS,
smartBalance: smartBalance ?? 0n,
rainSpendingPower: rainSpendingPowerToWei(rainCardOverview?.balance?.spendingPower),
rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'FIAT_OFFRAMP',
})
} catch (error) {
Expand Down
10 changes: 5 additions & 5 deletions src/components/Card/CancelCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRa
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { useWallet } from '@/hooks/wallet/useWallet'
import { rainSpendingPowerToWei } from '@/utils/balance.utils'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'

type Phase = 'confirm' | 'canceling' | 'feedback' | 'submitting-feedback' | 'thanks'

Expand Down Expand Up @@ -60,18 +60,18 @@ const CancelCardModal: FC<Props> = ({ cardId, isOpen, onClose }) => {
// Cancel can be terminal on Rain's side (collateral contract may
// become unreachable), so we MUST drain it BEFORE the cancel.
// Backend enforces order — this just delivers the signed body.
const spendingPowerWei = rainSpendingPowerToWei(overview?.balance?.spendingPower)
const spendingPowerUnits = rainCentsToUsdcUnits(overview?.balance?.spendingPower)
let verifiedWithdrawal: import('@/hooks/wallet/useSignSpendBundle').SignedRainWithdrawal | undefined
if (spendingPowerWei > 0n) {
if (spendingPowerUnits > 0n) {
if (!smartWalletAddress) {
throw new Error('Wallet not ready — please retry in a moment')
}
// Force collateral-only routing — same pattern as LockCardModal.
const artifact = await signSpend({
requiredUsdcAmount: spendingPowerWei,
requiredUsdcAmount: spendingPowerUnits,
recipient: smartWalletAddress as `0x${string}`,
smartBalance: 0n,
rainSpendingPower: spendingPowerWei,
rainSpendingPower: spendingPowerUnits,
kind: 'CRYPTO_WITHDRAW',
})
if (artifact.strategy !== 'collateral-only') {
Expand Down
10 changes: 5 additions & 5 deletions src/components/Card/LockCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRa
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { useWallet } from '@/hooks/wallet/useWallet'
import { rainSpendingPowerToWei } from '@/utils/balance.utils'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'

type Mode = 'lock' | 'unlock'
type Phase = 'prompt' | 'loading' | 'success' | 'error'
Expand Down Expand Up @@ -67,9 +67,9 @@ const LockCardModal: FC<Props> = ({ cardId, mode, isOpen, onClose }) => {
// smart wallet BEFORE locking so funds stay liquid. The
// backend gates the lock on a successful withdrawal — order
// is handled there. We only need to deliver the signed body.
const spendingPowerWei = rainSpendingPowerToWei(overview?.balance?.spendingPower)
const spendingPowerUnits = rainCentsToUsdcUnits(overview?.balance?.spendingPower)
let verifiedWithdrawal: import('@/hooks/wallet/useSignSpendBundle').SignedRainWithdrawal | undefined
if (spendingPowerWei > 0n) {
if (spendingPowerUnits > 0n) {
if (!smartWalletAddress) {
throw new Error('Wallet not ready — please retry in a moment')
}
Expand All @@ -78,10 +78,10 @@ const LockCardModal: FC<Props> = ({ cardId, mode, isOpen, onClose }) => {
// picks 'collateral-only' and signs a Rain withdrawal
// straight to the user's smart wallet (1 passkey tap).
const artifact = await signSpend({
requiredUsdcAmount: spendingPowerWei,
requiredUsdcAmount: spendingPowerUnits,
recipient: smartWalletAddress as `0x${string}`,
smartBalance: 0n,
rainSpendingPower: spendingPowerWei,
rainSpendingPower: spendingPowerUnits,
kind: 'CRYPTO_WITHDRAW',
})
if (artifact.strategy !== 'collateral-only') {
Expand Down
Loading
Loading