diff --git a/samples/frontend/src/App.tsx b/samples/frontend/src/App.tsx index 1a836c2f2..7cfde600b 100644 --- a/samples/frontend/src/App.tsx +++ b/samples/frontend/src/App.tsx @@ -2,6 +2,7 @@ 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 = { @@ -9,6 +10,10 @@ const FLOW_META: Record = { 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', @@ -30,6 +35,7 @@ export default function App() {

{meta.title}

{activeFlow === 'payout' && } + {activeFlow === 'usdc-payout' && } {activeFlow === 'embedded-wallet' && }
diff --git a/samples/frontend/src/components/Sidebar.tsx b/samples/frontend/src/components/Sidebar.tsx index 1c19b4a1f..1aa96ace7 100644 --- a/samples/frontend/src/components/Sidebar.tsx +++ b/samples/frontend/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -export type FlowKey = 'payout' | 'embedded-wallet' +export type FlowKey = 'payout' | 'usdc-payout' | 'embedded-wallet' interface FlowEntry { key: FlowKey @@ -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', diff --git a/samples/frontend/src/flows/PayoutFlow.tsx b/samples/frontend/src/flows/PayoutFlow.tsx index de80ab276..d9838cc7b 100644 --- a/samples/frontend/src/flows/PayoutFlow.tsx +++ b/samples/frontend/src/flows/PayoutFlow.tsx @@ -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' @@ -57,7 +57,7 @@ export default function PayoutFlow() { { setQuoteId((data.quoteId ?? data.id) as string) diff --git a/samples/frontend/src/flows/UsdcPayoutFlow.tsx b/samples/frontend/src/flows/UsdcPayoutFlow.tsx new file mode 100644 index 000000000..0c1d5f181 --- /dev/null +++ b/samples/frontend/src/flows/UsdcPayoutFlow.tsx @@ -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(null) + const [externalAccountId, setExternalAccountId] = useState(null) + const [quoteId, setQuoteId] = useState(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: ( + { + setCustomerId(data.id as string) + advance() + }} + /> + ), + }, + { + title: '2. Create USDC Wallet Account', + summary: externalAccountId ? `ID: ${externalAccountId}` : null, + content: ( + { + setExternalAccountId(data.id as string) + advance() + }} + /> + ), + }, + { + title: '3. Create Quote', + summary: quoteId ? `ID: ${quoteId}` : null, + content: ( + { + setQuoteId((data.quoteId ?? data.id) as string) + advance() + }} + /> + ), + }, + { + title: '4. Simulate Funding (Sandbox Only)', + summary: activeStep > 3 ? 'Funded' : null, + content: ( + advance()} + /> + ), + }, + ] + + return ( + <> + + {activeStep >= 1 && ( + + )} + + ) +} diff --git a/samples/frontend/src/steps/CreateQuote.tsx b/samples/frontend/src/steps/CreateQuote.tsx index a9905d2bf..c51f5445e 100644 --- a/samples/frontend/src/steps/CreateQuote.tsx +++ b/samples/frontend/src/steps/CreateQuote.tsx @@ -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) => 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(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [sourceCurrency, setSourceCurrency] = useState(SOURCE_CURRENCIES[0]) - const destCurrency = COUNTRY_CONFIGS[selectedCountry]?.currency ?? 'USD' - useEffect(() => { setBody(JSON.stringify({ source: { @@ -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]) const submit = async () => { setLoading(true) diff --git a/samples/frontend/src/steps/CreateUsdcExternalAccount.tsx b/samples/frontend/src/steps/CreateUsdcExternalAccount.tsx new file mode 100644 index 000000000..db594e89b --- /dev/null +++ b/samples/frontend/src/steps/CreateUsdcExternalAccount.tsx @@ -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) => 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 = { + 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(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + const config = NETWORK_CONFIGS[selectedNetwork] + setBody(JSON.stringify({ + customerId: customerId ?? "", + currency: "USDC", + platformAccountId: `acct_${Math.random().toString(36).slice(2, 10)}`, + accountInfo: { + accountType: config.accountType, + address: config.address, + }, + }, null, 2)) + }, [customerId, selectedNetwork]) + + const submit = async () => { + if (!customerId) return + setLoading(true) + setError(null) + setResponse(null) + try { + const data = await apiPost>( + `/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 ( +
+

+ Create a {config.description} wallet account for customer {customerId ?? '...'} +

+
+ + +
+ + + +
+ ) +} diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/ExternalAccounts.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/ExternalAccounts.kt index f72b408a4..ecdcba3fe 100644 --- a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/ExternalAccounts.kt +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/ExternalAccounts.kt @@ -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 import com.lightspark.grid.models.EurBeneficiary import com.lightspark.grid.models.EurExternalAccountCreateInfo import com.lightspark.grid.models.GbpExternalAccountCreateInfo @@ -10,6 +11,7 @@ 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 @@ -17,6 +19,9 @@ 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 @@ -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") } }