-
Notifications
You must be signed in to change notification settings - Fork 95
Add e2e solana example #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
13686d4
3b90080
08406ad
eb55978
d63da19
1d7ff40
3d4fcbc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,7 +29,14 @@ | |
| "@hono/node-server": "^1.14.2", | ||
| "@repo/api-utils": "workspace:*", | ||
| "@repo/cli-tools": "workspace:*", | ||
| "@solana-program/system": "^0.9.0", | ||
| "@solana-program/token": "^0.6.0", | ||
| "@solana/addresses": "^4.0.0", | ||
| "@solana/keys": "^4.0.0", | ||
| "@solana/kit": "^4.0.0", | ||
| "agentcommercekit": "workspace:*", | ||
| "bs58": "^6.0.0", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needed? |
||
| "gill": "^0.12.0", | ||
| "hono": "^4.7.10", | ||
| "valibot": "^1.1.0", | ||
| "viem": "^2.29.4" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,3 +26,17 @@ export const publicClient = createPublicClient({ | |
| chain, | ||
| transport: http() | ||
| }) | ||
|
|
||
| // Solana configuration for demo (devnet) | ||
| export const solana = { | ||
| // CAIP-2 chain id for Solana devnet | ||
| chainId: caip2ChainIds.solanaDevnet, | ||
| // Example RPC; users can override via environment if desired | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need for these comments |
||
| rpcUrl: process.env.SOLANA_RPC_URL ?? "https://api.devnet.solana.com", | ||
| // Example USDC devnet mint; replace if you prefer a different SPL mint | ||
| usdcMint: | ||
| process.env.SOLANA_USDC_MINT ?? | ||
| "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", | ||
| // Commitment to use for verification | ||
| commitment: "confirmed" as const | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,9 +12,30 @@ import { | |
| waitForEnter, | ||
| wordWrap | ||
| } from "@repo/cli-tools" | ||
| import { | ||
| address, | ||
| appendTransactionMessageInstructions, | ||
| createKeyPairSignerFromBytes, | ||
| createSolanaRpc, | ||
| createTransactionMessage, | ||
| getBase64EncodedWireTransaction, | ||
| pipe, | ||
| setTransactionMessageFeePayerSigner, | ||
| setTransactionMessageLifetimeUsingBlockhash, | ||
| signTransactionMessageWithSigners | ||
| } from "@solana/kit" | ||
| import { | ||
| TOKEN_PROGRAM_ADDRESS, | ||
| findAssociatedTokenPda, | ||
| getCreateAssociatedTokenInstructionAsync, | ||
| getTransferCheckedInstruction | ||
| } from "@solana-program/token" | ||
| import { | ||
| addressFromDidPkhUri, | ||
| createDidPkhUri, | ||
| createJwt, | ||
| createJwtSigner, | ||
| generateKeypair, | ||
| getDidResolver, | ||
| isDidPkhUri, | ||
| isJwtString, | ||
|
|
@@ -31,10 +52,14 @@ import { | |
| chain, | ||
| chainId, | ||
| publicClient, | ||
| solana, | ||
| usdcAddress | ||
| } from "./constants" | ||
| import { ensureNonZeroBalances } from "./utils/ensure-balances" | ||
| import { ensurePrivateKey } from "./utils/ensure-private-keys" | ||
| import { | ||
| ensureNonZeroBalances, | ||
| ensureSolanaSolBalance | ||
| } from "./utils/ensure-balances" | ||
| import { ensurePrivateKey, ensureSolanaKeys } from "./utils/ensure-private-keys" | ||
| import { getKeypairInfo } from "./utils/keypair-info" | ||
| import { transferUsdc } from "./utils/usdc-contract" | ||
| import type { KeypairInfo } from "./utils/keypair-info" | ||
|
|
@@ -153,9 +178,20 @@ The Client attempts to access a protected resource on the Server. Since no valid | |
| const selectedPaymentOptionId = await select({ | ||
| message: "Select which payment option to use", | ||
| choices: paymentOptions.map((option) => ({ | ||
| name: option.network === "stripe" ? "Stripe" : "Base Sepolia", | ||
| name: | ||
| option.network === "stripe" | ||
| ? "Stripe" | ||
| : option.network?.startsWith("solana:") | ||
| ? "Solana" | ||
| : "Base Sepolia", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be ideal to avoid nested ternary oeprators |
||
| value: option.id, | ||
| description: `Pay on ${option.network === "stripe" ? "Stripe" : "Base Sepolia"} using ${option.currency}` | ||
| description: `Pay on ${ | ||
| option.network === "stripe" | ||
| ? "Stripe" | ||
| : option.network?.startsWith("solana:") | ||
| ? "Solana" | ||
| : "Base Sepolia" | ||
| } using ${option.currency}` | ||
| })) | ||
| }) | ||
|
|
||
|
|
@@ -178,6 +214,17 @@ The Client attempts to access a protected resource on the Server. Since no valid | |
| ) | ||
| receipt = paymentResult.receipt | ||
| details = paymentResult.details | ||
| } else if ( | ||
| typeof selectedPaymentOption.network === "string" && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. needed? |
||
| selectedPaymentOption.network.startsWith("solana:") | ||
| ) { | ||
| const paymentResult = await performSolanaPayment( | ||
| clientKeypairInfo, | ||
| selectedPaymentOption, | ||
| paymentRequestToken | ||
| ) | ||
| receipt = paymentResult.receipt | ||
| details = paymentResult.details | ||
| } else if (selectedPaymentOption.network === chainId) { | ||
| const paymentResult = await performOnChainPayment( | ||
| clientKeypairInfo, | ||
|
|
@@ -400,6 +447,163 @@ If all checks pass, the Receipt Service issues a Verifiable Credential (VC) serv | |
| return { receipt, details } | ||
| } | ||
|
|
||
| async function performSolanaPayment( | ||
| client: KeypairInfo, | ||
| paymentOption: PaymentRequest["paymentOptions"][number], | ||
| paymentRequestToken: JwtString | ||
| ) { | ||
| const receiptServiceUrl = paymentOption.receiptService | ||
| if (!receiptServiceUrl) { | ||
| throw new Error(errorMessage("Receipt service URL is required")) | ||
| } | ||
|
|
||
| log(sectionHeader("💸 Execute Payment (Client Agent -> Solana / SPL Token)")) | ||
|
|
||
| const rpc = createSolanaRpc(solana.rpcUrl) | ||
| const clientSolKeys = await ensureSolanaKeys( | ||
| "SOLANA_CLIENT_PUBLIC_KEY", | ||
| "SOLANA_CLIENT_SECRET_KEY_JSON" | ||
| ) | ||
| const keyBytes = new Uint8Array( | ||
| JSON.parse(clientSolKeys.secretKeyJson) as number[] | ||
| ) | ||
| const payerSigner = await createKeyPairSignerFromBytes(keyBytes) | ||
|
|
||
| const mint = address(solana.usdcMint) | ||
|
|
||
| // Ensure payer has SOL for fees | ||
| await ensureSolanaSolBalance(clientSolKeys.publicKey) | ||
|
|
||
| const recipient = address(paymentOption.recipient) | ||
|
|
||
| const [senderAta] = await findAssociatedTokenPda({ | ||
| mint: mint, | ||
| owner: payerSigner.address, | ||
| tokenProgram: TOKEN_PROGRAM_ADDRESS | ||
| }) | ||
| const [recipientAta] = await findAssociatedTokenPda({ | ||
| mint: mint, | ||
| owner: recipient, | ||
| tokenProgram: TOKEN_PROGRAM_ADDRESS | ||
| }) | ||
|
|
||
| // Ensure sender has USDC balance; if not, prompt Circle faucet | ||
| let tokenBal: { amount: string } | ||
| try { | ||
| ;({ value: tokenBal } = await rpc | ||
| .getTokenAccountBalance(senderAta, { commitment: solana.commitment }) | ||
| .send()) | ||
| } catch (e: unknown) { | ||
| tokenBal = { amount: "0" } | ||
| } | ||
| while (tokenBal.amount === "0") { | ||
| log( | ||
| colors.dim( | ||
| "USDC balance is 0. Please request devnet USDC from Circle's faucet, then press Enter to retry." | ||
| ) | ||
| ) | ||
| log(colors.dim(`Send USDC to your wallet: ${clientSolKeys.publicKey}`)) | ||
| log(colors.cyan("https://faucet.circle.com/")) | ||
| await waitForEnter("Press Enter after funding USDC...") | ||
| try { | ||
| ;({ value: tokenBal } = await rpc | ||
| .getTokenAccountBalance(senderAta, { commitment: solana.commitment }) | ||
| .send()) | ||
| } catch (e: unknown) { | ||
| tokenBal = { amount: "0" } | ||
| } | ||
| } | ||
| const { value: recipientAtaInfo } = await rpc | ||
| .getAccountInfo(recipientAta, { | ||
| commitment: solana.commitment, | ||
| encoding: "base64" | ||
| }) | ||
| .send() | ||
| const maybeCreateRecipientAtaInstruction = !recipientAtaInfo | ||
| ? await getCreateAssociatedTokenInstructionAsync({ | ||
| payer: payerSigner, | ||
| owner: recipient, | ||
| mint: mint, | ||
| ata: recipientAta, | ||
| tokenProgram: TOKEN_PROGRAM_ADDRESS | ||
| }) | ||
| : undefined | ||
|
|
||
| const amount = BigInt(paymentOption.amount) | ||
| const transferIx = getTransferCheckedInstruction({ | ||
| source: senderAta, | ||
| destination: recipientAta, | ||
| mint, | ||
| authority: payerSigner.address, | ||
| amount, | ||
| decimals: paymentOption.decimals | ||
| }) | ||
|
|
||
| const { value: latestBlockhash } = await rpc.getLatestBlockhash().send() | ||
| const txMessage = pipe( | ||
| createTransactionMessage({ version: 0 }), | ||
| (m) => setTransactionMessageFeePayerSigner(payerSigner, m), | ||
| (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), | ||
| (m) => | ||
| appendTransactionMessageInstructions( | ||
| [ | ||
| ...(maybeCreateRecipientAtaInstruction | ||
| ? [maybeCreateRecipientAtaInstruction] | ||
| : []), | ||
| transferIx | ||
| ], | ||
| m | ||
| ) | ||
| ) | ||
| const signedTx = await signTransactionMessageWithSigners(txMessage) | ||
| const wireTx = getBase64EncodedWireTransaction(signedTx) | ||
| const base58Signature = await rpc | ||
| .sendTransaction(wireTx, { encoding: "base64" }) | ||
| .send() | ||
| const signature = base58Signature | ||
| log(colors.dim("View on Solana Explorer:")) | ||
| log(link(`https://explorer.solana.com/tx/${signature}?cluster=devnet`), { | ||
| wrap: false | ||
| }) | ||
|
|
||
| // Request receipt from receipt-service | ||
| // Sign with the actual Solana payer (Ed25519) and bind payerDid to Solana did:pkh | ||
| // Build an ACK signer from the same Ed25519 seed used for the Solana payer | ||
| const ackEd25519Keypair = await generateKeypair( | ||
| "Ed25519", | ||
| new Uint8Array(Array.from(keyBytes).slice(0, 32)) | ||
| ) | ||
| const ackEd25519JwtSigner = createJwtSigner(ackEd25519Keypair) | ||
| const payload = { | ||
| paymentRequestToken, | ||
| paymentOptionId: paymentOption.id, | ||
| metadata: { | ||
| network: solana.chainId, | ||
| txHash: signature | ||
| }, | ||
| payerDid: createDidPkhUri(solana.chainId, clientSolKeys.publicKey) | ||
| } | ||
| const signedPayload = await createJwt( | ||
| payload, | ||
| { | ||
| issuer: createDidPkhUri(solana.chainId, clientSolKeys.publicKey), | ||
| signer: ackEd25519JwtSigner | ||
| }, | ||
| { alg: "EdDSA" } | ||
| ) | ||
|
|
||
| const response = await fetch(receiptServiceUrl, { | ||
| method: "POST", | ||
| body: JSON.stringify({ payload: signedPayload }) | ||
| }) | ||
| const { receipt, details } = (await response.json()) as { | ||
| receipt: string | ||
| details: Verifiable<PaymentReceiptCredential> | ||
| } | ||
|
|
||
| return { receipt, details } | ||
| } | ||
Woody4618 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| async function performStripePayment( | ||
| _client: KeypairInfo, | ||
| paymentOption: PaymentRequest["paymentOptions"][number], | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are these strictly necessary if we install
@solana/kit?