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
6 changes: 6 additions & 0 deletions samples/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import { useState } from 'react'
import Sidebar, { FlowKey } from './components/Sidebar'
import WebhookStream from './components/WebhookStream'
import PayoutFlow from './flows/PayoutFlow'
import UsdcPayoutFlow from './flows/UsdcPayoutFlow'
import EmbeddedWalletFlow from './flows/EmbeddedWalletFlow'

const FLOW_META: Record<FlowKey, { title: string; subtitle: string }> = {
payout: {
title: 'Payout to Bank Account',
subtitle: 'Send a real time payment funded with USDC',
},
'usdc-payout': {
title: 'Send USDC to a Wallet',
subtitle: 'Send USDC on-chain to an external wallet, funded with USD',
},
'embedded-wallet': {
title: 'Global Account',
subtitle: 'Issue a self-custody dollar account and withdraw on behalf of a user',
Expand All @@ -30,6 +35,7 @@ export default function App() {
<main className="flex-1 p-6 min-h-[calc(100vh-73px)] max-w-5xl">
<h2 className="text-lg font-semibold mb-4">{meta.title}</h2>
{activeFlow === 'payout' && <PayoutFlow key="payout" />}
{activeFlow === 'usdc-payout' && <UsdcPayoutFlow key="usdc-payout" />}
{activeFlow === 'embedded-wallet' && <EmbeddedWalletFlow key="embedded-wallet" />}
</main>
</div>
Expand Down
7 changes: 6 additions & 1 deletion samples/frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type FlowKey = 'payout' | 'embedded-wallet'
export type FlowKey = 'payout' | 'usdc-payout' | 'embedded-wallet'

interface FlowEntry {
key: FlowKey
Expand All @@ -12,6 +12,11 @@ const FLOWS: FlowEntry[] = [
label: 'Payout to Bank Account',
description: 'Send a real-time payment funded with USDC',
},
{
key: 'usdc-payout',
label: 'Send USDC to a Wallet',
description: 'Send USDC on-chain to an external wallet, funded with USD',
},
{
key: 'embedded-wallet',
label: 'Global Account',
Expand Down
4 changes: 2 additions & 2 deletions samples/frontend/src/flows/PayoutFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react'
import StepWizard from '../components/StepWizard'
import CreateCustomer from '../steps/CreateCustomer'
import CreateExternalAccount from '../steps/CreateExternalAccount'
import CreateExternalAccount, { COUNTRY_CONFIGS } from '../steps/CreateExternalAccount'
import CreateQuote from '../steps/CreateQuote'
import SandboxFund from '../steps/SandboxFund'

Expand Down Expand Up @@ -57,7 +57,7 @@ export default function PayoutFlow() {
<CreateQuote
customerId={customerId}
externalAccountId={externalAccountId}
selectedCountry={selectedCountry}
destCurrency={COUNTRY_CONFIGS[selectedCountry]?.currency ?? 'USD'}
disabled={activeStep !== 2}
onComplete={(data) => {
setQuoteId((data.quoteId ?? data.id) as string)
Expand Down
95 changes: 95 additions & 0 deletions samples/frontend/src/flows/UsdcPayoutFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useState } from 'react'
import StepWizard from '../components/StepWizard'
import CreateCustomer from '../steps/CreateCustomer'
import CreateUsdcExternalAccount from '../steps/CreateUsdcExternalAccount'
import CreateQuote from '../steps/CreateQuote'
import SandboxFund from '../steps/SandboxFund'

export default function UsdcPayoutFlow() {
const [activeStep, setActiveStep] = useState(0)
const [customerId, setCustomerId] = useState<string | null>(null)
const [externalAccountId, setExternalAccountId] = useState<string | null>(null)
const [quoteId, setQuoteId] = useState<string | null>(null)
const [selectedNetwork, setSelectedNetwork] = useState('BASE')

const advance = () => setActiveStep((s) => s + 1)

const restartFromExternalAccount = () => {
setExternalAccountId(null)
setQuoteId(null)
setActiveStep(1)
}

const steps = [
{
title: '1. Create Customer',
summary: customerId ? `ID: ${customerId}` : null,
content: (
<CreateCustomer
disabled={activeStep !== 0}
onComplete={(data) => {
setCustomerId(data.id as string)
advance()
}}
/>
),
},
{
title: '2. Create USDC Wallet Account',
summary: externalAccountId ? `ID: ${externalAccountId}` : null,
content: (
<CreateUsdcExternalAccount
customerId={customerId}
disabled={activeStep !== 1}
selectedNetwork={selectedNetwork}
onNetworkChange={setSelectedNetwork}
onComplete={(data) => {
setExternalAccountId(data.id as string)
advance()
}}
/>
),
},
{
title: '3. Create Quote',
summary: quoteId ? `ID: ${quoteId}` : null,
content: (
<CreateQuote
customerId={customerId}
externalAccountId={externalAccountId}
destCurrency="USDC"
disabled={activeStep !== 2}
onComplete={(data) => {
setQuoteId((data.quoteId ?? data.id) as string)
advance()
}}
/>
),
},
{
title: '4. Simulate Funding (Sandbox Only)',
summary: activeStep > 3 ? 'Funded' : null,
content: (
<SandboxFund
quoteId={quoteId}
disabled={activeStep !== 3}
onComplete={() => advance()}
/>
),
},
]

return (
<>
<StepWizard steps={steps} activeStep={activeStep} />
{activeStep >= 1 && (
<button
onClick={restartFromExternalAccount}
className="mt-6 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300"
>
Start New Payment
</button>
)}
</>
)
}
9 changes: 3 additions & 6 deletions samples/frontend/src/steps/CreateQuote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,24 @@ import { useState, useEffect } from 'react'
import JsonEditor from '../components/JsonEditor'
import ResponsePanel from '../components/ResponsePanel'
import { apiPost } from '../lib/api'
import { COUNTRY_CONFIGS } from './CreateExternalAccount'

interface Props {
customerId: string | null
externalAccountId: string | null
selectedCountry: string
destCurrency: string
onComplete: (response: Record<string, unknown>) => void
disabled: boolean
}

const SOURCE_CURRENCIES = ['USD', 'USDC'] as const

export default function CreateQuote({ customerId, externalAccountId, selectedCountry, onComplete, disabled }: Props) {
export default function CreateQuote({ customerId, externalAccountId, destCurrency, onComplete, disabled }: Props) {
const [body, setBody] = useState('')
const [response, setResponse] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [sourceCurrency, setSourceCurrency] = useState<string>(SOURCE_CURRENCIES[0])

const destCurrency = COUNTRY_CONFIGS[selectedCountry]?.currency ?? 'USD'

useEffect(() => {
setBody(JSON.stringify({
source: {
Expand All @@ -38,7 +35,7 @@ export default function CreateQuote({ customerId, externalAccountId, selectedCou
lockedCurrencySide: "SENDING",
purposeOfPayment: "GIFT"
}, null, 2))
}, [customerId, externalAccountId, sourceCurrency, selectedCountry])
}, [customerId, externalAccountId, sourceCurrency, destCurrency])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 destCurrency is listed in the useEffect dependency array but is never referenced inside the effect body — it only appears in the JSX label on line 59. The effect will re-run whenever destCurrency changes (e.g., when the user picks a different country in PayoutFlow), but the regenerated body is identical to what was there before, silently discarding any edits the user made to the JSON.

Suggested change
}, [customerId, externalAccountId, sourceCurrency, destCurrency])
}, [customerId, externalAccountId, sourceCurrency])
Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/frontend/src/steps/CreateQuote.tsx
Line: 38

Comment:
`destCurrency` is listed in the `useEffect` dependency array but is never referenced inside the effect body — it only appears in the JSX label on line 59. The effect will re-run whenever `destCurrency` changes (e.g., when the user picks a different country in `PayoutFlow`), but the regenerated body is identical to what was there before, silently discarding any edits the user made to the JSON.

```suggestion
  }, [customerId, externalAccountId, sourceCurrency])
```

How can I resolve this? If you propose a fix, please make it concise.


const submit = async () => {
setLoading(true)
Expand Down
127 changes: 127 additions & 0 deletions samples/frontend/src/steps/CreateUsdcExternalAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react'
import JsonEditor from '../components/JsonEditor'
import ResponsePanel from '../components/ResponsePanel'
import { apiPost } from '../lib/api'

interface Props {
customerId: string | null
onComplete: (response: Record<string, unknown>) => void
disabled: boolean
selectedNetwork: string
onNetworkChange: (network: string) => void
}

// USDC is settled to an on-chain wallet address. Each network maps to a Grid
// wallet external-account type; EVM networks share the 0x address format.
const NETWORK_CONFIGS: Record<string, {
label: string
accountType: string
address: string
description: string
}> = {
BASE: {
label: "Base",
accountType: "BASE_WALLET",
address: "0xAbCDEF1234567890aBCdEf1234567890ABcDef12",
description: "USDC on Base",
},
ETHEREUM: {
label: "Ethereum",
accountType: "ETHEREUM_WALLET",
address: "0xAbCDEF1234567890aBCdEf1234567890ABcDef12",
description: "USDC on Ethereum",
},
POLYGON: {
label: "Polygon",
accountType: "POLYGON_WALLET",
address: "0xAbCDEF1234567890aBCdEf1234567890ABcDef12",
description: "USDC on Polygon",
},
SOLANA: {
label: "Solana",
accountType: "SOLANA_WALLET",
address: "7EYnhQoR9YM3N7UoaKRoA44Uy8JeaZV3qyouov87awMs",
description: "USDC on Solana",
},
TRON: {
label: "Tron",
accountType: "TRON_WALLET",
address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
description: "USDC on Tron",
},
}

export { NETWORK_CONFIGS }

export default function CreateUsdcExternalAccount({ customerId, onComplete, disabled, selectedNetwork, onNetworkChange }: Props) {
const [body, setBody] = useState('')
const [response, setResponse] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

useEffect(() => {
const config = NETWORK_CONFIGS[selectedNetwork]
setBody(JSON.stringify({
customerId: customerId ?? "<customer-id>",
currency: "USDC",
platformAccountId: `acct_${Math.random().toString(36).slice(2, 10)}`,
accountInfo: {
accountType: config.accountType,
address: config.address,
},
}, null, 2))
}, [customerId, selectedNetwork])
Comment on lines +62 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 platformAccountId regenerates a new random value every time the network selector changes, because it is computed inside the useEffect. Any edit the user makes to the JSON body is wiped on each network switch — including the ID they may have noted or copied.

Suggested change
useEffect(() => {
const config = NETWORK_CONFIGS[selectedNetwork]
setBody(JSON.stringify({
customerId: customerId ?? "<customer-id>",
currency: "USDC",
platformAccountId: `acct_${Math.random().toString(36).slice(2, 10)}`,
accountInfo: {
accountType: config.accountType,
address: config.address,
},
}, null, 2))
}, [customerId, selectedNetwork])
const stablePlatformAccountId = useRef(`acct_${Math.random().toString(36).slice(2, 10)}`)
useEffect(() => {
const config = NETWORK_CONFIGS[selectedNetwork]
setBody(JSON.stringify({
customerId: customerId ?? "<customer-id>",
currency: "USDC",
platformAccountId: stablePlatformAccountId.current,
accountInfo: {
accountType: config.accountType,
address: config.address,
},
}, null, 2))
}, [customerId, selectedNetwork])
Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/frontend/src/steps/CreateUsdcExternalAccount.tsx
Line: 62-73

Comment:
`platformAccountId` regenerates a new random value every time the network selector changes, because it is computed inside the `useEffect`. Any edit the user makes to the JSON body is wiped on each network switch — including the ID they may have noted or copied.

```suggestion
  const stablePlatformAccountId = useRef(`acct_${Math.random().toString(36).slice(2, 10)}`)

  useEffect(() => {
    const config = NETWORK_CONFIGS[selectedNetwork]
    setBody(JSON.stringify({
      customerId: customerId ?? "<customer-id>",
      currency: "USDC",
      platformAccountId: stablePlatformAccountId.current,
      accountInfo: {
        accountType: config.accountType,
        address: config.address,
      },
    }, null, 2))
  }, [customerId, selectedNetwork])
```

How can I resolve this? If you propose a fix, please make it concise.


const submit = async () => {
if (!customerId) return
setLoading(true)
setError(null)
setResponse(null)
try {
const data = await apiPost<Record<string, unknown>>(
`/api/customers/${customerId}/external-accounts`,
JSON.parse(body)
)
const pretty = JSON.stringify(data, null, 2)
setResponse(pretty)
onComplete(data)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}

const config = NETWORK_CONFIGS[selectedNetwork]

return (
<div>
<p className="text-sm text-gray-400 mb-2">
Create a {config.description} wallet account for customer <code className="text-blue-400">{customerId ?? '...'}</code>
</p>
<div className="mb-3">
<label htmlFor="usdc-network" className="block text-sm font-medium text-gray-300 mb-1">Network</label>
<select
id="usdc-network"
value={selectedNetwork}
onChange={(e) => onNetworkChange(e.target.value)}
disabled={disabled || loading}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-sm text-gray-100 focus:outline-none focus:border-blue-500"
>
{Object.entries(NETWORK_CONFIGS).map(([code, cfg]) => (
<option key={code} value={code}>{cfg.label}</option>
))}
</select>
</div>
<JsonEditor value={body} onChange={setBody} disabled={disabled || loading} />
<button
onClick={submit}
disabled={disabled || loading}
className="mt-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm font-medium"
>
{loading ? 'Creating...' : 'Create External Account'}
</button>
<ResponsePanel response={response} error={error} />
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.grid.sample.routes

import com.fasterxml.jackson.databind.JsonNode
import com.lightspark.grid.models.BrlExternalAccountCreateInfo
import com.lightspark.grid.models.EthereumWalletExternalAccountInfo
Comment thread
greptile-apps[bot] marked this conversation as resolved.
import com.lightspark.grid.models.EurBeneficiary
import com.lightspark.grid.models.EurExternalAccountCreateInfo
import com.lightspark.grid.models.GbpExternalAccountCreateInfo
Expand All @@ -10,13 +11,17 @@ import com.lightspark.grid.models.MxnExternalAccountCreateInfo
import com.lightspark.grid.models.PhpExternalAccountCreateInfo
import com.lightspark.grid.models.UsdExternalAccountCreateInfo
import com.lightspark.grid.models.customers.externalaccounts.Address
import com.lightspark.grid.models.customers.externalaccounts.BaseWalletInfo
import com.lightspark.grid.models.customers.externalaccounts.BrlBeneficiary
import com.lightspark.grid.models.customers.externalaccounts.ExternalAccountCreate
import com.lightspark.grid.models.customers.externalaccounts.ExternalAccountCreateParams
import com.lightspark.grid.models.customers.externalaccounts.GbpBeneficiary
import com.lightspark.grid.models.customers.externalaccounts.InrBeneficiary
import com.lightspark.grid.models.customers.externalaccounts.MxnBeneficiary
import com.lightspark.grid.models.customers.externalaccounts.PhpBeneficiary
import com.lightspark.grid.models.customers.externalaccounts.PolygonWalletInfo
import com.lightspark.grid.models.customers.externalaccounts.SolanaWalletInfo
import com.lightspark.grid.models.customers.externalaccounts.TronWalletInfo
import com.lightspark.grid.models.customers.externalaccounts.UsdBeneficiary
import com.grid.sample.GridClientBuilder
import com.grid.sample.JsonUtils
Expand Down Expand Up @@ -163,6 +168,43 @@ private fun buildAccountInfo(accountType: String, accountInfo: JsonNode): Extern
.build()
ExternalAccountCreate.AccountInfo.ofEurAccount(info)
}
// Crypto wallet destinations (e.g. for USDC payouts). These only need an
// on-chain address — no beneficiary.
"BASE_WALLET" -> {
val info = BaseWalletInfo.builder()
.accountType(BaseWalletInfo.AccountType.BASE_WALLET)
.address(accountInfo.requireText("address"))
.build()
ExternalAccountCreate.AccountInfo.ofBaseWallet(info)
}
"ETHEREUM_WALLET" -> {
val info = EthereumWalletExternalAccountInfo.builder()
.accountType(EthereumWalletExternalAccountInfo.AccountType.ETHEREUM_WALLET)
.address(accountInfo.requireText("address"))
.build()
ExternalAccountCreate.AccountInfo.ofEthereumWalletExternal(info)
}
"POLYGON_WALLET" -> {
val info = PolygonWalletInfo.builder()
.accountType(PolygonWalletInfo.AccountType.POLYGON_WALLET)
.address(accountInfo.requireText("address"))
.build()
ExternalAccountCreate.AccountInfo.ofPolygonWallet(info)
}
"SOLANA_WALLET" -> {
val info = SolanaWalletInfo.builder()
.accountType(SolanaWalletInfo.AccountType.SOLANA_WALLET)
.address(accountInfo.requireText("address"))
.build()
ExternalAccountCreate.AccountInfo.ofSolanaWallet(info)
}
"TRON_WALLET" -> {
val info = TronWalletInfo.builder()
.accountType(TronWalletInfo.AccountType.TRON_WALLET)
.address(accountInfo.requireText("address"))
.build()
ExternalAccountCreate.AccountInfo.ofTronWallet(info)
}
else -> throw IllegalArgumentException("Unsupported account type: $accountType")
}
}
Expand Down
Loading