Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
171 changes: 135 additions & 36 deletions apps/dialog/src/lib/guestMode.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,79 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Address } from 'ox'
import type { Hex } from 'ox/Hex'
import * as Secp256k1 from 'ox/Secp256k1'
import type * as Messenger from 'porto/core/Messenger'
import type { Account } from 'porto/viem'
import * as Account from 'porto/viem/Account'
import * as React from 'react'
import * as Dialog from './Dialog'
import { porto } from './Porto'

export type GuestStatus = 'disabled' | 'enabled' | 'signing-in' | 'signing-up'

export function useGuestMode(currentAccount?: Account.Account) {
const [guestModeAccount, setGuestModeAccount] =
React.useState<Account.Account>()
const [guestStatus, setGuestStatus] = React.useState<GuestStatus>('disabled')
const queryClient = useQueryClient()

const guestModeAccount = useQuery({
enabled: !currentAccount,
async queryFn() {
const { storage } = porto._internal.config
let pk = await storage.getItem<Hex>('porto.guestMode.key')

// only generate new key if none exists (may have funds)
if (!pk) {
pk = Secp256k1.randomPrivateKey()
await storage.setItem('porto.guestMode.key', pk)
queryClient.invalidateQueries({ queryKey: ['guestMode', 'hasKey'] })
}

return Account.fromPrivateKey(pk)
},
queryKey: ['guestMode', 'account'],
staleTime: Number.POSITIVE_INFINITY,
})

React.useEffect(() => {
if (!currentAccount && !guestModeAccount) setGuestStatus('enabled')
}, [currentAccount, guestModeAccount])
if (currentAccount) return
if (!guestModeAccount.data) return
porto._internal.store.setState((state) => ({
...state,
accounts: [guestModeAccount.data],
}))
}, [guestModeAccount.data, currentAccount])

if (guestModeAccount.data && guestStatus === 'disabled')
setGuestStatus('enabled')

const skipStaleCheck = React.useRef(false)
React.useEffect(() => {
if (currentAccount) {
skipStaleCheck.current = false
return
}

if (skipStaleCheck.current) return
if (guestModeAccount.isFetching) return

// keep dialogs in sync e.g. to use the iframe after signup in popup
skipStaleCheck.current = true
const { getItem } = porto._internal.config.storage
void Promise.resolve(getItem<Hex>('porto.guestMode.key')).then(
(key) => {
// no key in storage but have cached guest => guest was upgraded
if (!key && guestModeAccount.data)
queryClient.invalidateQueries({ queryKey: ['guestMode'] })
},
(err) => {
console.error('Failed to check guest mode key:', err)
},
)
}, [
currentAccount,
guestModeAccount.data,
guestModeAccount.isFetching,
queryClient,
])

const handleGuestSignIn = React.useCallback(async () => {
setGuestStatus('signing-in')
Expand All @@ -23,53 +83,92 @@ export function useGuestMode(currentAccount?: Account.Account) {
params: [{}],
})
const newAccount = response.accounts?.[0]
const [portoAccount] = porto._internal.store.getState().accounts
if (newAccount && portoAccount) {
setGuestModeAccount(portoAccount)
if (newAccount) {
porto.messenger.send('account', {
account: newAccount as Messenger.Payload<'account'>['account'],
})
setGuestStatus('disabled')
queryClient.invalidateQueries({ queryKey: ['guestMode'] })
}
} catch (error) {
if (Dialog.handleWebAuthnIframeError(error)) return
setGuestStatus('enabled')
}
}, [])
}, [queryClient.invalidateQueries])

const handleGuestSignUp = React.useCallback(async (email?: string) => {
setGuestStatus('signing-up')
try {
const response = await porto.provider.request({
method: 'wallet_connect',
params: [
{
capabilities: {
createAccount: email ? { label: email } : true,
email: Boolean(email),
const handleGuestSignUp = React.useCallback(
async (email?: string) => {
setGuestStatus('signing-up')
try {
const storage = porto._internal.config.storage

// get stored ephemeral private key
const key = await storage.getItem<Hex>('porto.guestMode.key')
if (!key) throw new Error('No ephemeral account found')

// call wallet_connect with ephemeral eoa to upgrade it with WebAuthn
const response = await porto.provider.request({
method: 'wallet_connect',
params: [
{
capabilities: {
createAccount: {
eoa: key,
label: email,
},
email: Boolean(email),
},
},
},
],
})
const newAccount = response.accounts?.[0]
const [portoAccount] = porto._internal.store.getState().accounts
if (newAccount && portoAccount) {
setGuestModeAccount(portoAccount)
porto.messenger.send('account', {
account: newAccount as Messenger.Payload<'account'>['account'],
],
})
setGuestStatus('disabled')

const newAccount = response.accounts?.[0]
if (newAccount) {
porto.messenger.send('account', {
account: newAccount as Messenger.Payload<'account'>['account'],
})
setGuestStatus('disabled')

// guest was upgraded => delete ephemeral key
await storage.removeItem('porto.guestMode.key')

queryClient.invalidateQueries({ queryKey: ['guestMode'] })
}
} catch (error) {
console.error('Failed to upgrade ephemeral account:', error)
if (Dialog.handleWebAuthnIframeError(error)) return
setGuestStatus('enabled')
}
} catch (error) {
if (Dialog.handleWebAuthnIframeError(error)) return
setGuestStatus('enabled')
}
}, [])
},
[queryClient.invalidateQueries],
)

const hasGuestKeyQuery = useQuery({
async queryFn() {
const { storage } = porto._internal.config
const key = await storage.getItem('porto.guestMode.key')
return Boolean(key)
},
queryKey: ['guestMode', 'hasKey'],
})

const addressesMatch = Boolean(
guestModeAccount.data &&
currentAccount &&
Address.isEqual(currentAccount.address, guestModeAccount.data.address),
)

// ephemeral = non-persistent but connected guest account
// i.e. current account matches guest key and key exists in storage
const isEphemeral = hasGuestKeyQuery.isLoading
? addressesMatch
: Boolean(hasGuestKeyQuery.data && addressesMatch)

return {
guestModeAccount,
guestStatus,
account: guestModeAccount.data,
isEphemeral,
onSignIn: handleGuestSignIn,
onSignUp: handleGuestSignUp,
status: guestStatus,
}
}
3 changes: 2 additions & 1 deletion apps/dialog/src/routes/-components/ActionRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,15 @@ export function ActionRequest(props: ActionRequest.Props) {
const refreshingQuote = prepareCallsQuery.isRefetching

const guestModeData: GuestMode | undefined = React.useMemo(() => {
if (!guestMode) return undefined
if (!guestStatus || guestStatus === 'disabled') return undefined
if (!onGuestSignIn || !onGuestSignUp) return undefined
return {
onSignIn: onGuestSignIn,
onSignUp: onGuestSignUp,
status: guestStatus,
}
}, [guestStatus, onGuestSignIn, onGuestSignUp])
}, [guestMode, guestStatus, onGuestSignIn, onGuestSignUp])

const insufficientFunds = React.useMemo(() => {
const errorMessage = prepareCallsQuery.error?.message ?? ''
Expand Down
22 changes: 12 additions & 10 deletions apps/dialog/src/routes/dialog/eth_sendTransaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,17 @@ function RouteComponent() {

const currentAccount = Hooks.useAccount(porto)
const client = Hooks.useRelayClient(porto, { chainId })
const guestMode = useGuestMode(currentAccount)

const { guestModeAccount, guestStatus, onSignIn, onSignUp } =
useGuestMode(currentAccount)
const account = guestMode.isEphemeral
? guestMode.account
: (currentAccount ?? guestMode.account)

const account = currentAccount ?? guestModeAccount

const preview = account
? { account, address: account.address, guest: false }
: undefined
const preview = account && {
account,
address: account.address,
guest: guestMode.isEphemeral || !currentAccount,
}

const respond = useMutation({
// TODO: use EIP-1193 Provider + `wallet_sendPreparedCalls` in the future
Expand Down Expand Up @@ -108,12 +110,12 @@ function RouteComponent() {
chainId={chainId}
feeToken={feeToken}
guestMode={preview?.guest}
guestStatus={guestStatus}
guestStatus={guestMode.status}
loading={respond.isPending}
merchantUrl={merchantUrl}
onApprove={(data) => respond.mutate(data)}
onGuestSignIn={onSignIn}
onGuestSignUp={onSignUp}
onGuestSignIn={guestMode.onSignIn}
onGuestSignUp={guestMode.onSignUp}
onReject={() => respond.mutate({ reject: true })}
/>
)
Expand Down
14 changes: 6 additions & 8 deletions apps/dialog/src/routes/dialog/wallet_addFunds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ function RouteComponent() {
: {}

const currentAccount = Hooks.useAccount(porto, { address })
const { guestModeAccount, guestStatus, onSignIn, onSignUp } = useGuestMode(
address ? currentAccount : undefined,
)
const guestMode = useGuestMode(currentAccount)

const account = address ? currentAccount : guestModeAccount
const account = currentAccount ?? guestMode.account

const respond = useMutation({
async mutationFn(
Expand All @@ -50,11 +48,11 @@ function RouteComponent() {
<AddFunds
address={account?.address ?? address}
chainId={chainId}
guestMode={!address && !guestModeAccount}
guestStatus={guestStatus}
guestMode={guestMode.isEphemeral || !currentAccount}
guestStatus={guestMode.status}
onApprove={(result) => respond.mutate(result)}
onGuestSignIn={onSignIn}
onGuestSignUp={onSignUp}
onGuestSignIn={guestMode.onSignIn}
onGuestSignUp={guestMode.onSignUp}
onReject={() => respond.mutate({ reject: true })}
value={value}
/>
Expand Down
24 changes: 12 additions & 12 deletions apps/dialog/src/routes/dialog/wallet_sendCalls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createFileRoute } from '@tanstack/react-router'
import * as Provider from 'ox/Provider'
import { Actions, Hooks } from 'porto/remote'
import { RelayActions } from 'porto/viem'
import * as React from 'react'
import type * as Calls from '~/lib/Calls'
import { useGuestMode } from '~/lib/guestMode'
import { porto } from '~/lib/Porto'
Expand All @@ -26,16 +25,17 @@ function RouteComponent() {

const currentAccount = Hooks.useAccount(porto)
const client = Hooks.useRelayClient(porto, { chainId })
const guestMode = useGuestMode(currentAccount)

const { guestModeAccount, guestStatus, onSignIn, onSignUp } =
useGuestMode(currentAccount)
const account = guestMode.isEphemeral
? guestMode.account
: (currentAccount ?? guestMode.account)

const account = currentAccount ?? guestModeAccount

const preview = React.useMemo(() => {
if (account) return { account, address: account.address, guest: false }
return undefined
}, [account])
const preview = account && {
account,
address: account.address,
guest: guestMode.isEphemeral || !currentAccount,
}

const respond = useMutation({
// TODO: use EIP-1193 Provider + `wallet_sendPreparedCalls` in the future
Expand Down Expand Up @@ -88,12 +88,12 @@ function RouteComponent() {
chainId={chainId}
feeToken={feeToken}
guestMode={preview?.guest}
guestStatus={guestStatus}
guestStatus={guestMode.status}
loading={respond.isPending}
merchantUrl={merchantUrl}
onApprove={(data) => respond.mutate(data)}
onGuestSignIn={onSignIn}
onGuestSignUp={onSignUp}
onGuestSignIn={guestMode.onSignIn}
onGuestSignUp={guestMode.onSignUp}
onReject={() => respond.mutate({ reject: true })}
requiredFunds={requiredFunds}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/core/internal/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,11 +789,12 @@ export function from<

const { accounts } = await (async () => {
if (email || createAccount) {
const { label = undefined } =
const { eoa, label = undefined } =
typeof createAccount === 'object' ? createAccount : {}
const { account } = await getMode().actions.createAccount({
admins,
email,
eoa: eoa && Account.fromPrivateKey(eoa),
internal,
label,
permissions,
Expand Down
1 change: 1 addition & 0 deletions src/core/internal/schema/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export namespace createAccount {
z.boolean(),
z.object({
chainId: z.optional(u.number()),
eoa: z.optional(u.hex()),
label: z.optional(z.string()),
}),
])
Expand Down
Loading