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: 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
7 changes: 7 additions & 0 deletions demos/payments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor

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?

"@solana/kit": "^4.0.0",
"agentcommercekit": "workspace:*",
"bs58": "^6.0.0",
Copy link
Contributor

Choose a reason for hiding this comment

The 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"
Expand Down
14 changes: 14 additions & 0 deletions demos/payments/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
}
212 changes: 208 additions & 4 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 {
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,
Expand All @@ -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"
Expand Down Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The 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}`
}))
})

Expand All @@ -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" &&
Copy link
Contributor

Choose a reason for hiding this comment

The 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,
Expand Down Expand Up @@ -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 }
}

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