Skip to content
Merged
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
5 changes: 5 additions & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ language: en
words:
- agentcommercekit
- bitstring
- blockhash
- caip
- dbname
- devnet
- healthcheck
- keypair
- lamports
- multibase
- multicall
- multicodec
- multikey
- pubkeys
- secp256
- skyfire
- solana
Expand Down
6 changes: 4 additions & 2 deletions demos/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This interactive command-line demo showcases a common use case: the **Server-Ini

- A **Client Agent** attempting to access a protected resource.
- A **Server Agent** requiring payment and issuing a formal Payment Request.
- The Client Agent making a payment using **USDC on the Base Sepolia testnet**.
- The Client Agent making a payment using **USDC on the Base Sepolia testnet** or **USDC on Solana devnet**, or a **Stripe** simulated card payment.
- A **Receipt Service** verifying the on-chain payment and issuing a cryptographically **Verifiable Credential (VC)** as a payment receipt.
- The Client Agent using this receipt to gain access to the resource.

Expand Down Expand Up @@ -50,7 +50,9 @@ pnpm run demo
The interactive CLI will guide you through the following steps:

1. **Client requests resource**: The Client attempts to fetch data from the Server Agent, who responds with an HTTP `402 Payment Required` status. This response contains a `PaymentRequest` which includes details on how to pay for access to this resource and offers multiple payment options.
2. **Client makes payment**: If the client chooses to pay via Credit Card, they will pay via a sample Payment Service. Alternatively, the Client can use the information from the Payment Request to transfer USDC from its wallet to the Server's wallet on the Base Sepolia testnet.
2. **Client makes payment**: If the client chooses to pay via Credit Card, they will pay via a sample Payment Service. Alternatively, the Client can use the information from the Payment Request to transfer USDC from its wallet to the Server's wallet on:
- Base Sepolia (EVM), or
- Solana devnet (SPL token, USDC mint configurable via env)
3. **Client requests a receipt**: Once the payment transaction is complete, the Client or the Payment Service will request a formal Receipt **Verifiable Credential (VC)**. For on-chain payments, the Client provides the Receipt Service with proof of the on-chain transaction and the original Payment Request.
4. **Receipt Service verifies payment**: The Receipt Service verifies all of the provided data, performs on-chain transaction verification if required, and verifies the integrity of the original payment request. If all is successful, it issues a Receipt Credential (VC).
5. **Client presents receipt to Server**: The Client retries the request to the Server, this time presenting the Verifiable Credential (receipt). The Server verifies the receipt and, if valid, grants access to the protected resource.
Expand Down
3 changes: 3 additions & 0 deletions demos/payments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"@hono/node-server": "catalog:",
"@repo/api-utils": "workspace:*",
"@repo/cli-tools": "workspace:*",
"@solana-program/system": "^0.9.0",
"@solana-program/token": "^0.6.0",
"@solana/kit": "^4.0.0",
"agentcommercekit": "workspace:*",
"hono": "catalog:",
"valibot": "catalog:",
Expand Down
9 changes: 9 additions & 0 deletions demos/payments/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,12 @@ export const publicClient = createPublicClient({
chain,
transport: http(),
})

export const solana = {
chainId: caip2ChainIds.solanaDevnet,
rpcUrl: process.env.SOLANA_RPC_URL ?? "https://api.devnet.solana.com",
usdcMint:
process.env.SOLANA_USDC_MINT ??
"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
commitment: "confirmed" as const,
}
244 changes: 220 additions & 24 deletions demos/payments/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,30 @@ import {
waitForEnter,
wordWrap,
} from "@repo/cli-tools"
import {
findAssociatedTokenPda,
getCreateAssociatedTokenInstructionAsync,
getTransferCheckedInstruction,
TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token"
import {
address,
appendTransactionMessageInstructions,
createKeyPairSignerFromBytes,
createSolanaRpc,
createTransactionMessage,
getBase64EncodedWireTransaction,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from "@solana/kit"
import {
addressFromDidPkhUri,
createDidPkhUri,
createJwt,
createJwtSigner,
generateKeypair,
getDidResolver,
isDidPkhUri,
isJwtString,
Expand All @@ -35,10 +56,14 @@ import {
chainId,
publicClient,
SERVER_URL,
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, type KeypairInfo } from "./utils/keypair-info"
import { transferUsdc } from "./utils/usdc-contract"
import "./server"
Expand Down Expand Up @@ -147,12 +172,19 @@ The Client attempts to access a protected resource on the Server. Since no valid
)

const paymentOptions = paymentRequest.paymentOptions

function networkLabel(network: string | undefined): string {
if (network === "stripe") return "Stripe"
if (network?.startsWith("solana:")) return "Solana"
return "Base Sepolia"
}

const selectedPaymentOptionId = await select({
message: "Select which payment option to use",
choices: paymentOptions.map((option) => ({
name: option.network === "stripe" ? "Stripe" : "Base Sepolia",
name: networkLabel(option.network),
value: option.id,
description: `Pay on ${option.network === "stripe" ? "Stripe" : "Base Sepolia"} using ${option.currency}`,
description: `Pay on ${networkLabel(option.network)} using ${option.currency}`,
})),
})

Expand All @@ -164,29 +196,38 @@ The Client attempts to access a protected resource on the Server. Since no valid
throw new Error(errorMessage("Invalid payment option"))
}

let receipt: string
let details: Verifiable<PaymentReceiptCredential>

if (selectedPaymentOption.network === "stripe") {
const paymentResult = await performStripePayment(
clientKeypairInfo,
selectedPaymentOption,
paymentRequestToken,
)
receipt = paymentResult.receipt
details = paymentResult.details
} else if (selectedPaymentOption.network === chainId) {
const paymentResult = await performOnChainPayment(
clientKeypairInfo,
selectedPaymentOption,
paymentRequestToken,
)
receipt = paymentResult.receipt
details = paymentResult.details
} else {
function executePayment(
option: PaymentRequest["paymentOptions"][number],
): Promise<{
receipt: string
details: Verifiable<PaymentReceiptCredential>
}> {
if (option.network === "stripe") {
return performStripePayment(
clientKeypairInfo,
option,
paymentRequestToken,
)
}
if (option.network?.startsWith("solana:")) {
return performSolanaPayment(
clientKeypairInfo,
option,
paymentRequestToken,
)
}
if (option.network === chainId) {
return performOnChainPayment(
clientKeypairInfo,
option,
paymentRequestToken,
)
}
throw new Error(errorMessage("Invalid payment option"))
}

const { receipt, details } = await executePayment(selectedPaymentOption)

log(
successMessage(
"Verifiable Payment Receipt (VC) issued successfully by the Receipt Service! 🎉",
Expand Down Expand Up @@ -397,6 +438,161 @@ 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,
owner: payerSigner.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
const [recipientAta] = await findAssociatedTokenPda({
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 {
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 {
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,
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 signature = await rpc
.sendTransaction(wireTx, { encoding: "base64" })
.send()
log(colors.dim("View on Solana Explorer:"))
log(link(`https://explorer.solana.com/tx/${signature}?cluster=devnet`), {
wrap: false,
})

// Build an ACK Ed25519 signer from the Solana payer seed to sign the receipt request
const ackEd25519Keypair = await generateKeypair(
"Ed25519",
new Uint8Array(Array.from(keyBytes).slice(0, 32)),
)
const ackEd25519JwtSigner = createJwtSigner(ackEd25519Keypair)
const payerDid = createDidPkhUri(solana.chainId, clientSolKeys.publicKey)
const payload = {
paymentRequestToken,
paymentOptionId: paymentOption.id,
metadata: {
network: solana.chainId,
txHash: signature,
},
payerDid,
}
const signedPayload = await createJwt(
payload,
{
issuer: payerDid,
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 }
}

async function performStripePayment(
_client: KeypairInfo,
paymentOption: PaymentRequest["paymentOptions"][number],
Expand Down
Loading