Last active
July 19, 2025 10:28
-
-
Save AlmostEfficient/76a071f644968e22747ddc01f74559fb to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'react-native-get-random-values'; | |
import { v4 as uuidv4 } from 'uuid'; | |
const GRID_BASE_URL = 'https://grid.squads.xyz/api/v0'; | |
const GRID_API_KEY = process.env.EXPO_PUBLIC_GRID_API_KEY; | |
const GRID_ENVIRONMENT = process.env.EXPO_PUBLIC_GRID_ENVIRONMENT; // Change to 'production' when ready | |
if (!GRID_API_KEY) { | |
throw new Error('Missing Grid API key. Set EXPO_PUBLIC_GRID_API_KEY in your environment.'); | |
} | |
// All signing logic moved to Turnkey backend - /api/signatures/mpc endpoint | |
export interface GridSmartAccount { | |
smart_account_address: string; | |
grid_user_id: string; | |
policies: { | |
authorities: Array<{ | |
address: string; | |
permissions: string[]; | |
}>; | |
admin_address: string | null; | |
threshold: number; | |
time_lock: string | null; | |
}; | |
created_at: string; | |
} | |
export interface GridKyc { | |
id: string; | |
customer_id: string; | |
kyc_link: string; | |
created_at: string; | |
} | |
export interface GridPaymentIntent { | |
id: string; | |
payment_rail: string; | |
amount: string; | |
currency: string; | |
source: any; | |
destination: any; | |
status: string; | |
created_at: string; | |
updated_at: string; | |
authorities: string[]; | |
threshold: number; | |
intent_payload: string; | |
mpc_payload: string; | |
} | |
export class GridService { | |
private requestQueue: Array<() => Promise<any>> = []; | |
private isProcessing = false; | |
private lastRequestTime = 0; | |
private readonly REQUEST_INTERVAL = 1100; // 1.1 seconds to be safe | |
private async processQueue() { | |
if (this.isProcessing || this.requestQueue.length === 0) return; | |
this.isProcessing = true; | |
while (this.requestQueue.length > 0) { | |
const now = Date.now(); | |
const timeSinceLastRequest = now - this.lastRequestTime; | |
if (timeSinceLastRequest < this.REQUEST_INTERVAL) { | |
await new Promise(resolve => | |
setTimeout(resolve, this.REQUEST_INTERVAL - timeSinceLastRequest) | |
); | |
} | |
const request = this.requestQueue.shift(); | |
if (request) { | |
this.lastRequestTime = Date.now(); | |
try { | |
await request(); | |
} catch (error) { | |
console.error('Queued request failed:', error); | |
} | |
} | |
} | |
this.isProcessing = false; | |
} | |
private queueRequest<T>(requestFn: () => Promise<T>): Promise<T> { | |
return new Promise((resolve, reject) => { | |
this.requestQueue.push(async () => { | |
try { | |
const result = await requestFn(); | |
resolve(result); | |
} catch (error) { | |
reject(error); | |
} | |
}); | |
this.processQueue(); | |
}); | |
} | |
private async request<T>( | |
endpoint: string, | |
options: RequestInit = {} | |
): Promise<{ data: T; metadata: any }> { | |
const url = `${GRID_BASE_URL}${endpoint}`; | |
console.log(`Grid API Request: ${options.method || 'GET'} ${url}`); | |
const response = await fetch(url, { | |
...options, | |
headers: { | |
'Content-Type': 'application/json', | |
'x-grid-environment': GRID_ENVIRONMENT || 'development', | |
'Authorization': `Bearer ${GRID_API_KEY}`, | |
...options.headers, | |
}, | |
}); | |
console.log(`Grid API Response: ${response.status} ${response.statusText}`); | |
if (!response.ok) { | |
let errorData; | |
try { | |
errorData = await response.json(); | |
console.error('Grid API Error Response:', errorData); | |
} catch (parseError) { | |
console.error('Failed to parse error response:', parseError); | |
errorData = { | |
message: `HTTP ${response.status}: ${response.statusText}`, | |
status: response.status, | |
statusText: response.statusText | |
}; | |
} | |
const errorMessage = errorData.message || | |
errorData.error || | |
errorData.details || | |
`Grid API error: ${response.status} ${response.statusText}`; | |
throw new Error(errorMessage); | |
} | |
const responseData = await response.json(); | |
console.log('Grid API Success Response:', responseData); | |
return responseData; | |
} | |
private generateIdempotencyKey(): string { | |
try { | |
return uuidv4(); | |
} catch (error) { | |
// Fallback to timestamp + random number if UUID fails | |
const timestamp = Date.now(); | |
const random = Math.floor(Math.random() * 1000000); | |
return `payment-${timestamp}-${random}`; | |
} | |
} | |
async requestKyc( | |
smartAccountAddress: string, | |
gridUserId: string, | |
email: string, | |
fullName: string | |
): Promise<GridKyc> { | |
const idempotencyKey = `kyc-${gridUserId}-${Date.now()}`; | |
const { data } = await this.request<GridKyc>( | |
`/grid/smart-accounts/${smartAccountAddress}/kyc`, | |
{ | |
method: 'POST', | |
headers: { | |
'X-Idempotency-Key': idempotencyKey, | |
}, | |
body: JSON.stringify({ | |
grid_user_id: gridUserId, | |
type: 'individual', | |
email, | |
full_name: fullName, | |
endorsements: [] | |
}), | |
} | |
); | |
return data; | |
} | |
async getKycStatus(smartAccountAddress: string, kycId: string) { | |
const { data } = await this.request( | |
`/grid/smart-accounts/${smartAccountAddress}/kyc/${kycId}` | |
); | |
return data; | |
} | |
async createVirtualAccount(smartAccountAddress: string, gridUserId: string, currency: string = 'usd') { | |
const idempotencyKey = `virtual-${gridUserId}-${currency}-${Date.now()}`; | |
const { data } = await this.request( | |
`/grid/smart-accounts/${smartAccountAddress}/virtual-accounts`, | |
{ | |
method: 'POST', | |
headers: { | |
'X-Idempotency-Key': idempotencyKey, | |
}, | |
body: JSON.stringify({ | |
grid_user_id: gridUserId, | |
currency | |
}), | |
} | |
); | |
return data; | |
} | |
async createPaymentIntent( | |
smartAccountAddress: string, | |
gridUserId: string, | |
amount: string, | |
source: any, | |
destination: any | |
): Promise<GridPaymentIntent> { | |
const { data } = await this.request<GridPaymentIntent>( | |
`/grid/smart-accounts/${smartAccountAddress}/payment-intents`, | |
{ | |
method: 'POST', | |
body: JSON.stringify({ | |
amount, | |
grid_user_id: gridUserId, | |
source, | |
destination | |
}), | |
} | |
); | |
return data; | |
} | |
// New payment flow methods for chunk 2 | |
async preparePaymentIntent( | |
smartAccountAddress: string, | |
params: { | |
amount: string; | |
grid_user_id: string; | |
source: any; | |
destination: any; | |
}, | |
useMpcProvider: boolean = true | |
): Promise<any> { | |
const endpoint = useMpcProvider | |
? `/grid/smart-accounts/${smartAccountAddress}/payment-intents?use-mpc-provider=true` | |
: `/grid/smart-accounts/${smartAccountAddress}/payment-intents`; | |
const { data } = await this.request(endpoint, { | |
method: 'POST', | |
headers: { | |
'x-idempotency-key': this.generateIdempotencyKey(), | |
}, | |
body: JSON.stringify(params), | |
}); | |
return data; | |
} | |
async confirmPaymentIntent( | |
smartAccountAddress: string, | |
paymentIntentId: string, | |
payload: { | |
intent_payload?: string; | |
signature?: string; // For Grid v0 authorization | |
mpc_payload?: string; // Grid API expects snake_case | |
intentPayload?: string; // Legacy MPC | |
mpcPayload?: string; // Legacy MPC | |
}, | |
useMpcProvider: boolean = false | |
): Promise<any> { | |
const endpoint = useMpcProvider | |
? `/grid/smart-accounts/${smartAccountAddress}/payment-intents/${paymentIntentId}/confirm?use-mpc-provider=true` | |
: `/grid/smart-accounts/${smartAccountAddress}/payment-intents/${paymentIntentId}/confirm?use-mpc-provider=false`; | |
const { data } = await this.request(endpoint, { | |
method: 'POST', | |
headers: { | |
'x-idempotency-key': this.generateIdempotencyKey(), | |
}, | |
body: JSON.stringify(payload), | |
}); | |
return data; | |
} | |
async getBalance(smartAccountAddress: string) { | |
return this.queueRequest(async () => { | |
try { | |
const { data } = await this.request( | |
`/grid/smart-accounts/${smartAccountAddress}/balances` | |
); | |
console.log('π Raw balance response:', JSON.stringify(data, null, 2)); | |
const balanceArray = (data as any).balances || data; | |
if (Array.isArray(balanceArray) && balanceArray.length === 0) { | |
console.log(`π Check smart account: https://solscan.io/account/${smartAccountAddress}`); | |
// Query on-chain balances as fallback | |
const response = await fetch('https://api.mainnet-beta.solana.com', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify([ | |
{ | |
// Query SOL balance | |
jsonrpc: '2.0', | |
id: 1, | |
method: 'getBalance', | |
params: [smartAccountAddress] | |
}, | |
{ | |
// Query USDC balance | |
jsonrpc: '2.0', | |
id: 2, | |
method: 'getTokenAccountsByOwner', | |
params: [ | |
smartAccountAddress, | |
{ | |
mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' // USDC mint | |
}, | |
{ | |
encoding: 'jsonParsed' | |
} | |
] | |
} | |
]) | |
}); | |
const [solResponse, usdcResponse] = await response.json(); | |
// Parse SOL balance | |
const solBalance = (solResponse.result?.value || 0) / 1e9; | |
// Parse USDC balance from token accounts | |
let usdcBalance = 0; | |
if (usdcResponse.result?.value) { | |
for (const account of usdcResponse.result.value) { | |
if (account.account.data.parsed.info.mint === 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') { | |
usdcBalance += Number(account.account.data.parsed.info.tokenAmount.uiAmount || 0); | |
} | |
} | |
} | |
console.log('π° On-chain balances:', { sol: solBalance, usdc: usdcBalance }); | |
return { | |
usdc: usdcBalance.toString(), | |
sol: solBalance.toString() | |
}; | |
} | |
if (Array.isArray(balanceArray)) { | |
const balances: any = {}; | |
balanceArray.forEach((balance: any) => { | |
console.log('π° Balance item:', balance); | |
// Fix: Use symbol instead of currency, and amount_decimal instead of amount | |
if (balance.symbol && balance.amount_decimal !== undefined) { | |
balances[balance.symbol.toLowerCase()] = balance.amount_decimal; | |
} | |
}); | |
// Ensure we always return both usdc and sol | |
return { | |
usdc: balances.usdc || '0', | |
sol: balances.sol || '0' | |
}; | |
} | |
return { usdc: '0', sol: '0' }; | |
} catch (error: any) { | |
console.error('Balance API error:', error); | |
if (error.message?.includes('not found') || error.message?.includes('404')) { | |
return { usdc: '0', sol: '0' }; | |
} | |
throw error; | |
} | |
}); | |
} | |
async getTransferHistory(smartAccountAddress: string) { | |
return this.queueRequest(async () => { | |
try { | |
const { data } = await this.request( | |
`/grid/smart-accounts/${smartAccountAddress}/payment-intents` | |
); | |
return data || []; | |
} catch (error: any) { | |
// Handle empty transfer history gracefully | |
if (error.message?.includes('not found') || error.message?.includes('404')) { | |
return []; // New account with no transfers | |
} | |
throw error; | |
} | |
}); | |
} | |
// Associate smart account with MPC provider | |
async associateWithMpcProvider(smartAccountAddress: string, publicKey: string) { | |
console.log('Attempting MPC Association:', { smartAccountAddress, publicKey: publicKey.slice(0, 10) + '...' }); | |
try { | |
const payload = { | |
public_key: publicKey, | |
provider: 'turnkey' | |
}; | |
console.log('MPC Association Payload:', payload); | |
const { data } = await this.request( | |
`/grid/smart-accounts/${smartAccountAddress}/mpc-provider`, | |
{ | |
method: 'POST', | |
body: JSON.stringify(payload), | |
} | |
); | |
console.log('MPC Association Success:', data); | |
return { success: true, data, message: 'Successfully associated with MPC provider' }; | |
} catch (error: any) { | |
console.error('MPC Association Error Details:', { | |
message: error.message, | |
stack: error.stack, | |
name: error.name | |
}); | |
// If already associated, that's fine | |
if (error.message?.includes('already associated') || | |
error.message?.includes('already exists') || | |
error.message?.includes('conflict')) { | |
return { success: true, message: 'Already associated with MPC provider' }; | |
} | |
// More specific error handling | |
if (error.message?.includes('smart account not found') || error.message?.includes('404')) { | |
throw new Error('Smart account not found. Please create one first.'); | |
} | |
if (error.message?.includes('invalid public key') || error.message?.includes('400')) { | |
throw new Error('Invalid public key format.'); | |
} | |
if (error.message?.includes('401') || error.message?.includes('unauthorized')) { | |
throw new Error('Authentication failed. Check API key.'); | |
} | |
if (error.message?.includes('403') || error.message?.includes('forbidden')) { | |
throw new Error('Access denied. Check permissions.'); | |
} | |
// Generic error with more context | |
const errorMsg = error.message || 'Unknown error during MPC association'; | |
throw new Error(`MPC association failed: ${errorMsg}`); | |
} | |
} | |
} | |
export const gridService = new GridService(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { createContext, useContext, useEffect, useState } from 'react'; | |
import { GridSmartAccount, GridKyc, gridService } from '../grid'; | |
import AsyncStorage from '@react-native-async-storage/async-storage'; | |
import { usdcToBaseUnits, usdcFromBaseUnits, formatUSDC, isValidSolanaAddress } from '../../utils/helpers'; | |
import { useTurnkey } from './TurnkeyContext'; | |
import { useTurnkey as useTurnkeySDK, TurnkeyClient, PasskeyStamper } from '@turnkey/sdk-react-native'; | |
import { useAuth } from './useAuth'; | |
import { walletsApi } from '../wallets'; | |
import { isDev } from '../app'; | |
import { TURNKEY_CONFIG } from '../constants'; | |
interface GridContextType { | |
smartAccount: GridSmartAccount | null; | |
kyc: GridKyc | null; | |
isRequestingKyc: boolean; | |
error: string | null; | |
requestKyc: (email: string, fullName: string) => Promise<GridKyc>; | |
getKycStatus: () => Promise<any>; | |
createVirtualAccount: (currency?: string) => Promise<any>; | |
clearGridData: () => Promise<void>; | |
refreshSmartAccount: () => Promise<void>; | |
hasSmartAccount: boolean; | |
testTurnkeyAuthFlow: () => Promise<{ success: boolean; message: string }>; | |
testUSDCFormatting: () => { success: boolean; results: any }; | |
testAddressValidation: (address: string) => boolean; | |
runChunk1Tests: () => Promise<{ success: boolean; results: any[] }>; | |
// Chunk 2: Payment Flow Methods | |
preparePaymentIntent: (amount: string, recipientAddress: string) => Promise<any>; | |
confirmPayment: (paymentIntent: any, mpcPayload: any) => Promise<any>; | |
getBalance: () => Promise<{ usdc: string; sol: string }>; | |
getTransferHistory: () => Promise<any[]>; | |
// Complete payment flow (prepare β sign β confirm) | |
sendUSDC: (amount: string, recipientAddress: string) => Promise<any>; | |
// Turnkey backend integration | |
signWithTurnkey: (mpcPayload: string) => Promise<{ requestParameters: any; stamp: { publicKey: string; signature: string } }>; | |
// Debug helpers | |
checkSmartAccountOnChain: () => Promise<{ exists: boolean; hasSOL: boolean; details: any }>; | |
} | |
const GridContext = createContext<GridContextType | undefined>(undefined); | |
interface GridProviderProps { | |
children: React.ReactNode; | |
} | |
export function GridProvider({ children }: GridProviderProps) { | |
const [smartAccount, setSmartAccount] = useState<GridSmartAccount | null>(null); | |
const [kyc, setKyc] = useState<GridKyc | null>(null); | |
const [isRequestingKyc, setIsRequestingKyc] = useState(false); | |
const [error, setError] = useState<string | null>(null); | |
// Turnkey integration for signing | |
const { wallet: turnkeyWallet, signMpcPayload } = useTurnkey(); | |
const turnkeySDK = useTurnkeySDK(); | |
const { user } = useAuth(); | |
// Load stored data on mount and when user changes | |
useEffect(() => { | |
loadStoredData(); | |
}, [user]); // Re-load when user changes | |
const loadStoredData = async () => { | |
try { | |
// Clear state when user logs out | |
if (!user) { | |
setSmartAccount(null); | |
setKyc(null); | |
setError(null); | |
return; | |
} | |
const dbWallet = await walletsApi.getPrimaryWallet(); | |
if (dbWallet?.grid_address && dbWallet?.grid_user_id) { | |
// Derive smart account from database wallet | |
const smartAccountFromDB: GridSmartAccount = { | |
smart_account_address: dbWallet.grid_address, | |
grid_user_id: dbWallet.grid_user_id, | |
policies: { | |
authorities: [{ | |
address: dbWallet.turnkey_address, | |
permissions: ['CAN_INITIATE', 'CAN_VOTE', 'CAN_EXECUTE'] | |
}], | |
admin_address: null, | |
threshold: 1, | |
time_lock: null | |
}, | |
created_at: dbWallet.created_at || new Date().toISOString() | |
}; | |
setSmartAccount(smartAccountFromDB); | |
} | |
// Keep KYC in AsyncStorage (it's not in DB) | |
const storedKyc = await AsyncStorage.getItem('grid_kyc'); | |
if (storedKyc) { | |
setKyc(JSON.parse(storedKyc)); | |
} | |
} catch (err) { | |
console.error('Error loading Grid data:', err); | |
} | |
}; | |
const requestKyc = async (email: string, fullName: string): Promise<GridKyc> => { | |
if (!smartAccount) { | |
throw new Error('No smart account found. Create one first.'); | |
} | |
setIsRequestingKyc(true); | |
setError(null); | |
try { | |
const kycData = await gridService.requestKyc( | |
smartAccount.smart_account_address, | |
smartAccount.grid_user_id, | |
email, | |
fullName | |
); | |
setKyc(kycData); | |
await AsyncStorage.setItem('grid_kyc', JSON.stringify(kycData)); | |
return kycData; | |
} catch (err) { | |
const errorMessage = err instanceof Error ? err.message : 'Failed to request KYC'; | |
setError(errorMessage); | |
throw err; | |
} finally { | |
setIsRequestingKyc(false); | |
} | |
}; | |
const getKycStatus = async () => { | |
if (!smartAccount || !kyc) { | |
throw new Error('No KYC found'); | |
} | |
try { | |
const status = await gridService.getKycStatus( | |
smartAccount.smart_account_address, | |
kyc.id | |
); | |
return status; | |
} catch (err) { | |
const errorMessage = err instanceof Error ? err.message : 'Failed to get KYC status'; | |
setError(errorMessage); | |
throw err; | |
} | |
}; | |
const createVirtualAccount = async (currency: string = 'usd') => { | |
if (!smartAccount) { | |
throw new Error('No smart account found'); | |
} | |
try { | |
const virtualAccount = await gridService.createVirtualAccount( | |
smartAccount.smart_account_address, | |
smartAccount.grid_user_id, | |
currency | |
); | |
return virtualAccount; | |
} catch (err) { | |
const errorMessage = err instanceof Error ? err.message : 'Failed to create virtual account'; | |
setError(errorMessage); | |
throw err; | |
} | |
}; | |
// Turnkey backend integration for signing MPC payloads (legacy) | |
const signWithTurnkey = async (mpcPayload: string) => { | |
if (!turnkeyWallet) { | |
throw new Error('No Turnkey wallet available for signing'); | |
} | |
try { | |
console.log('π Signing MPC payload with Turnkey...'); | |
// Use TurnkeyContext's signMpcPayload method (which handles the backend call) | |
const mockMode = isDev; | |
const result = await signMpcPayload(mpcPayload, mockMode); | |
console.log('β Turnkey signing successful'); | |
return { | |
requestParameters: result.requestParameters, | |
stamp: { | |
publicKey: result.stamp.publicKey, | |
signature: result.stamp.signature | |
} | |
}; | |
} catch (err) { | |
console.error('β Turnkey signing failed:', err); | |
throw new Error(`Signing failed: ${err instanceof Error ? err.message : 'Unknown error'}`); | |
} | |
}; | |
// Grid v0 authorization - sign payment intent with user's passkey | |
const signGridPaymentIntent = async (intentPayload: string) => { | |
if (!turnkeyWallet) { | |
throw new Error('No Turnkey wallet available for signing'); | |
} | |
try { | |
console.log('π Signing payment intent with user passkey...'); | |
// ================================ | |
// DATA FLOW DOCUMENTATION: | |
// ================================ | |
// π€ GRID GIVES US: paymentIntent.intent_payload (base64 encoded Solana transaction) | |
// π§ WE NEED TO: Convert base64 β hex for Turnkey signing | |
// π TURNKEY EXPECTS: hex payload with PAYLOAD_ENCODING_HEXADECIMAL | |
// π TURNKEY GIVES US: ED25519 signature in response.activity.result.signRawPayloadResult.r | |
// π₯ GRID EXPECTS: signature field in confirmPayment (hex format) | |
// ================================ | |
console.log('π Signing params:', { | |
walletAddress: turnkeyWallet.turnkeyAddress, | |
intentPayloadLength: intentPayload.length, | |
payloadPreview: intentPayload.substring(0, 50) + '...' | |
}); | |
// Debug: Let's understand what we're working with | |
console.log('π PAYLOAD ANALYSIS:'); | |
console.log('π€ What Grid gave us:', { | |
type: typeof intentPayload, | |
length: intentPayload.length, | |
first50: intentPayload.substring(0, 50), | |
last10: intentPayload.substring(intentPayload.length - 10), | |
isBase64Like: /^[A-Za-z0-9+/]*={0,2}$/.test(intentPayload), | |
isHexLike: /^[0-9a-fA-F]*$/.test(intentPayload) | |
}); | |
// Try multiple conversion approaches | |
let hexPayload: string; | |
let conversionMethod: string; | |
if (/^[0-9a-fA-F]*$/.test(intentPayload)) { | |
// Already hex | |
hexPayload = intentPayload; | |
conversionMethod = 'already-hex'; | |
} else if (/^[A-Za-z0-9+/]*={0,2}$/.test(intentPayload)) { | |
// Looks like base64 - use React Native compatible conversion | |
try { | |
// Convert base64 to hex using native JavaScript | |
const binaryString = atob(intentPayload); // base64 to binary string | |
hexPayload = Array.from(binaryString) | |
.map(char => char.charCodeAt(0).toString(16).padStart(2, '0')) | |
.join(''); | |
conversionMethod = 'base64-to-hex'; | |
// Additional validation - make sure conversion worked | |
console.log('π§ͺ Base64 conversion test:', { | |
originalSample: intentPayload.substring(0, 20), | |
binaryLength: binaryString.length, | |
hexSample: hexPayload.substring(0, 40) | |
}); | |
} catch (err) { | |
console.error('Base64 conversion failed:', err); | |
// Fallback: treat as UTF-8 string and convert to hex | |
hexPayload = Array.from(intentPayload) | |
.map(char => char.charCodeAt(0).toString(16).padStart(2, '0')) | |
.join(''); | |
conversionMethod = 'utf8-to-hex'; | |
} | |
} else { | |
// Unknown format, try as UTF-8 | |
hexPayload = Array.from(intentPayload) | |
.map(char => char.charCodeAt(0).toString(16).padStart(2, '0')) | |
.join(''); | |
conversionMethod = 'utf8-to-hex-fallback'; | |
} | |
console.log('π CONVERSION RESULT:', { | |
method: conversionMethod, | |
originalLength: intentPayload.length, | |
hexLength: hexPayload.length, | |
hexFirst50: hexPayload.substring(0, 50), | |
isValidHex: /^[0-9a-fA-F]*$/.test(hexPayload), | |
// Check if conversion actually changed anything | |
conversionWorked: hexPayload !== intentPayload | |
}); | |
// SAFETY CHECK: If conversion didn't work, fail fast | |
if (hexPayload === intentPayload && conversionMethod === 'base64-to-hex') { | |
throw new Error('Base64 to hex conversion failed - payload unchanged'); | |
} | |
// Create passkey stamper (this triggers the passkey prompt) | |
const stamper = new PasskeyStamper({ | |
rpId: TURNKEY_CONFIG.RP_ID, | |
}); | |
// Create Turnkey client with passkey stamper | |
const httpClient = new TurnkeyClient( | |
{ baseUrl: TURNKEY_CONFIG.TURNKEY_BASE_URL }, | |
stamper | |
); | |
console.log('π Signing with fresh TurnkeyClient...'); | |
console.log('π Turnkey signing params:', { | |
organizationId: turnkeyWallet.subOrganizationId, | |
signWith: turnkeyWallet.turnkeyAddress, | |
payloadLength: hexPayload.length, | |
encoding: "PAYLOAD_ENCODING_HEXADECIMAL", | |
hashFunction: "HASH_FUNCTION_NOT_APPLICABLE" // ED25519 handles hashing internally | |
}); | |
// Sign the raw payload using the passkey | |
const signResult = await httpClient.signRawPayload({ | |
type: "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2", | |
timestampMs: Date.now().toString(), | |
organizationId: turnkeyWallet.subOrganizationId, // Use the wallet's sub-org ID | |
parameters: { | |
signWith: turnkeyWallet.turnkeyAddress, | |
payload: hexPayload, | |
encoding: "PAYLOAD_ENCODING_HEXADECIMAL", | |
hashFunction: "HASH_FUNCTION_NOT_APPLICABLE" // ED25519 handles hashing internally | |
} | |
}); | |
console.log('β Payment intent signed with passkey'); | |
console.log('π Full Turnkey response:', JSON.stringify(signResult, null, 2)); | |
// Extract signature from Turnkey response - ED25519 might need full signature | |
const signRawResult = signResult.activity.result.signRawPayloadResult; | |
const rComponent = signRawResult?.r; | |
const sComponent = signRawResult?.s; | |
if (!rComponent) { | |
throw new Error('No signature returned from Turnkey'); | |
} | |
// ED25519 signature should be exactly 64 bytes (128 hex chars) | |
// Turnkey returns r and s as separate 32-byte components | |
if (!rComponent || !sComponent) { | |
throw new Error('Missing signature components from Turnkey'); | |
} | |
// Remove any "0x" prefixes and ensure lowercase | |
const rClean = rComponent.replace(/^0x/, '').toLowerCase(); | |
const sClean = sComponent.replace(/^0x/, '').toLowerCase(); | |
// Validate component lengths (should be 32 bytes = 64 hex chars each) | |
if (rClean.length !== 64 || sClean.length !== 64) { | |
throw new Error(`Invalid signature component lengths: r=${rClean.length}, s=${sClean.length}`); | |
} | |
// For ED25519, signature is r||s (concatenated) | |
const signature = rClean + sClean; | |
console.log('π ED25519 signature formatted:', { | |
rLength: rClean.length, | |
sLength: sClean.length, | |
totalLength: signature.length, | |
signaturePreview: signature.substring(0, 40) + '...', | |
isValidLength: signature.length === 128 | |
}); | |
return { | |
signature: signature, | |
signer: turnkeyWallet.turnkeyAddress, | |
timestamp: new Date().toISOString(), | |
type: 'passkey_ed25519' | |
}; | |
} catch (err) { | |
console.error('β Payment intent signing failed:', err); | |
throw new Error(`Signing failed: ${err instanceof Error ? err.message : 'Unknown error'}`); | |
} | |
}; | |
const testTurnkeyAuthFlow = async (): Promise<{ success: boolean; message: string }> => { | |
try { | |
if (!turnkeyWallet) { | |
return { success: false, message: 'No Turnkey wallet available' }; | |
} | |
// Test signing a mock payment intent | |
const mockPaymentIntent = JSON.stringify({ | |
amount: '1000000', // 1 USDC in base units | |
destination: 'mockAddress123', | |
timestamp: Date.now() | |
}); | |
const result = await signWithTurnkey(mockPaymentIntent); | |
if (result.stamp.publicKey && result.stamp.signature) { | |
return { | |
success: true, | |
message: `Turnkey auth flow test passed. Public key: ${result.stamp.publicKey.slice(0, 10)}...` | |
}; | |
} else { | |
return { success: false, message: 'Invalid signature response' }; | |
} | |
} catch (err) { | |
return { | |
success: false, | |
message: `Turnkey auth test failed: ${err instanceof Error ? err.message : 'Unknown error'}` | |
}; | |
} | |
}; | |
const testUSDCFormatting = () => { | |
try { | |
const tests = [ | |
{ input: '1.5', expected: '1500000' }, | |
{ input: '0.000001', expected: '1' }, | |
{ input: '100', expected: '100000000' } | |
]; | |
const results = tests.map(test => { | |
const baseUnits = usdcToBaseUnits(test.input); | |
const backToUI = usdcFromBaseUnits(baseUnits); | |
const formatted = formatUSDC(test.input); | |
return { | |
input: test.input, | |
baseUnits, | |
backToUI, | |
formatted, | |
success: baseUnits === test.expected | |
}; | |
}); | |
const allPassed = results.every(r => r.success); | |
return { | |
success: allPassed, | |
results | |
}; | |
} catch (err) { | |
return { | |
success: false, | |
results: { error: err instanceof Error ? err.message : 'Unknown error' } | |
}; | |
} | |
}; | |
const testAddressValidation = (address: string): boolean => { | |
return isValidSolanaAddress(address); | |
}; | |
// Comprehensive test function for Chunk 1 validation | |
const runChunk1Tests = async (): Promise<{ success: boolean; results: any[] }> => { | |
const results = []; | |
let allPassed = true; | |
// Test 1: Turnkey authentication | |
const authTest = await testTurnkeyAuthFlow(); | |
results.push({ | |
test: 'Turnkey Auth Flow', | |
success: authTest.success, | |
message: authTest.message | |
}); | |
if (!authTest.success) allPassed = false; | |
// Test 2: USDC formatting | |
const usdcTest = testUSDCFormatting(); | |
results.push({ | |
test: 'USDC Formatting', | |
success: usdcTest.success, | |
message: usdcTest.success ? 'All USDC conversions passed' : 'Some USDC conversions failed', | |
details: usdcTest.results | |
}); | |
if (!usdcTest.success) allPassed = false; | |
// Test 3: Address validation | |
const validAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // Real USDC mint address | |
const invalidAddress = 'invalid-address-123'; | |
const validTest = testAddressValidation(validAddress); | |
const invalidTest = !testAddressValidation(invalidAddress); | |
results.push({ | |
test: 'Address Validation', | |
success: validTest && invalidTest, | |
message: validTest && invalidTest ? 'Address validation working correctly' : 'Address validation failed', | |
details: { validTest, invalidTest } | |
}); | |
if (!(validTest && invalidTest)) allPassed = false; | |
return { success: allPassed, results }; | |
}; | |
const getBalance = async (): Promise<{ usdc: string; sol: string }> => { | |
if (!smartAccount) { | |
throw new Error('No smart account found'); | |
} | |
try { | |
const balanceData = await gridService.getBalance(smartAccount.smart_account_address) as any; | |
// Extract USDC and SOL balances from the response | |
// This format may vary based on Grid's API response structure | |
const usdcBalance = balanceData.usdc || '0'; | |
const solBalance = balanceData.sol || '0'; | |
return { | |
usdc: usdcBalance, // Grid already returns decimal format, don't double-convert | |
sol: solBalance | |
}; | |
} catch (err) { | |
console.error('Balance fetch error:', err); | |
const errorMessage = err instanceof Error ? err.message : 'Failed to get balance'; | |
// For testing purposes, return zero balances instead of throwing | |
if (errorMessage.includes('not found') || errorMessage.includes('404')) { | |
return { usdc: '0', sol: '0' }; | |
} | |
setError(errorMessage); | |
throw err; | |
} | |
}; | |
const getTransferHistory = async (): Promise<any[]> => { | |
if (!smartAccount) { | |
throw new Error('No smart account found'); | |
} | |
try { | |
const transfers = await gridService.getTransferHistory(smartAccount.smart_account_address) as any[]; | |
return transfers; | |
} catch (err) { | |
console.error('Transfer history fetch error:', err); | |
const errorMessage = err instanceof Error ? err.message : 'Failed to get transfer history'; | |
// For testing purposes, return empty array instead of throwing for new accounts | |
if (errorMessage.includes('not found') || errorMessage.includes('404')) { | |
return []; | |
} | |
setError(errorMessage); | |
throw err; | |
} | |
}; | |
// Chunk 2: Payment Flow Methods | |
const preparePaymentIntent = async (amount: string, recipientAddress: string) => { | |
if (!smartAccount) { | |
throw new Error('No smart account found. Create one first.'); | |
} | |
if (!isValidSolanaAddress(recipientAddress)) { | |
throw new Error('Invalid recipient address'); | |
} | |
try { | |
// Convert USDC amount to base units (6 decimals) | |
const baseAmount = usdcToBaseUnits(amount); | |
const params = { | |
amount: baseAmount, | |
grid_user_id: smartAccount.grid_user_id, | |
source: { | |
smart_account_address: smartAccount.smart_account_address, | |
currency: 'usdc', | |
authorities: [smartAccount.policies.authorities[0].address] // Turnkey signer address | |
}, | |
destination: { | |
address: recipientAddress, | |
currency: 'usdc' | |
} | |
}; | |
const paymentIntent = await gridService.preparePaymentIntent( | |
smartAccount.smart_account_address, | |
params, | |
false // Use Grid v0 authorization (not MPC) to match backend | |
); | |
return paymentIntent; | |
} catch (err) { | |
const errorMessage = err instanceof Error ? err.message : 'Failed to prepare payment intent'; | |
setError(errorMessage); | |
throw err; | |
} | |
}; | |
const confirmPayment = async (paymentIntent: any, signedPayload: any) => { | |
if (!smartAccount) { | |
throw new Error('No smart account found'); | |
} | |
try { | |
console.log('π Sending to Grid:', { | |
intentPayload: paymentIntent.intent_payload ? 'Present' : 'Missing', | |
signature: signedPayload.signature ? 'Present' : 'Missing' | |
}); | |
const response = await gridService.confirmPaymentIntent( | |
smartAccount.smart_account_address, | |
paymentIntent.id, | |
{ | |
intentPayload: paymentIntent.intent_payload, // Grid actually expects camelCase despite docs | |
mpcPayload: JSON.stringify({ | |
signature: signedPayload.signature, | |
signer: signedPayload.signer, | |
type: signedPayload.type | |
}) // Grid expects mpcPayload field even for non-MPC | |
}, | |
false // Use Grid v0 authorization (not MPC) to match backend | |
); | |
return response; | |
} catch (err) { | |
const errorMessage = err instanceof Error ? err.message : 'Failed to confirm payment'; | |
setError(errorMessage); | |
throw err; | |
} | |
}; | |
// Complete payment flow: prepare β sign β confirm | |
const sendUSDC = async (amount: string, recipientAddress: string) => { | |
if (!smartAccount || !turnkeyWallet) { | |
throw new Error('Smart account or Turnkey wallet not ready'); | |
} | |
try { | |
console.log('π Starting USDC transfer...'); | |
// Step 1: Prepare payment intent with Grid | |
const paymentIntent = await preparePaymentIntent(amount, recipientAddress); | |
console.log('β Payment intent prepared:', paymentIntent.id); | |
// Step 2: Sign intent payload with Turnkey backend for Grid v0 | |
const signedPayload = await signGridPaymentIntent(paymentIntent.intent_payload); | |
console.log('β Payment signed with Turnkey'); | |
// Step 3: Confirm payment with Grid | |
const result = await confirmPayment(paymentIntent, signedPayload); | |
console.log('β Payment confirmed with Grid'); | |
return result; | |
} catch (err) { | |
console.error('β USDC transfer failed:', err); | |
throw err; | |
} | |
}; | |
// Debug helper to check smart account on-chain | |
const checkSmartAccountOnChain = async (): Promise<{ exists: boolean; hasSOL: boolean; details: any }> => { | |
if (!smartAccount) { | |
throw new Error('No smart account found'); | |
} | |
try { | |
// Use a simple RPC call to check if the account exists | |
const response = await fetch('https://api.mainnet-beta.solana.com', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
jsonrpc: '2.0', | |
id: 1, | |
method: 'getAccountInfo', | |
params: [ | |
smartAccount.smart_account_address, | |
{ encoding: 'base64' } | |
] | |
}) | |
}); | |
const { result } = await response.json(); | |
if (result?.value) { | |
const lamports = result.value.lamports; | |
const hasSOL = lamports > 0; | |
console.log(`π Smart Account ${smartAccount.smart_account_address}:`); | |
console.log(` - Exists: YES`); | |
console.log(` - SOL Balance: ${lamports / 1e9} SOL`); | |
console.log(` - Has SOL: ${hasSOL}`); | |
return { | |
exists: true, | |
hasSOL, | |
details: { | |
lamports, | |
solBalance: lamports / 1e9, | |
executable: result.value.executable, | |
owner: result.value.owner | |
} | |
}; | |
} else { | |
console.log(`β Smart Account ${smartAccount.smart_account_address} does not exist on-chain`); | |
return { exists: false, hasSOL: false, details: null }; | |
} | |
} catch (err) { | |
console.error('Failed to check smart account on-chain:', err); | |
throw err; | |
} | |
}; | |
const clearGridData = async () => { | |
try { | |
// Only clear KYC from AsyncStorage - smart account comes from database | |
await AsyncStorage.removeItem('grid_kyc'); | |
setSmartAccount(null); | |
setKyc(null); | |
setError(null); | |
} catch (err) { | |
console.error('Error clearing Grid data:', err); | |
} | |
}; | |
const refreshSmartAccount = async () => { | |
try { | |
console.log('π Refreshing smart account data...'); | |
await loadStoredData(); | |
console.log('β Smart account data refreshed'); | |
} catch (err) { | |
console.error('Error refreshing smart account data:', err); | |
} | |
}; | |
const value: GridContextType = { | |
smartAccount, | |
kyc, | |
isRequestingKyc, | |
error, | |
requestKyc, | |
getKycStatus, | |
createVirtualAccount, | |
clearGridData, | |
refreshSmartAccount, | |
hasSmartAccount: !!smartAccount, | |
// Updated authorization and testing methods (now using Turnkey) | |
testTurnkeyAuthFlow, | |
testUSDCFormatting, | |
testAddressValidation, | |
runChunk1Tests, | |
// Chunk 2: Payment Flow Methods (now using Turnkey backend) | |
preparePaymentIntent, | |
confirmPayment, | |
getBalance, | |
getTransferHistory, | |
sendUSDC, | |
signWithTurnkey, | |
// Debug helpers | |
checkSmartAccountOnChain, | |
}; | |
return <GridContext.Provider value={value}>{children}</GridContext.Provider>; | |
} | |
export function useGrid(): GridContextType { | |
const context = useContext(GridContext); | |
if (context === undefined) { | |
throw new Error('useGrid must be used within a GridProvider'); | |
} | |
return context; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment