Initial commit
This commit is contained in:
43
packages/x402/README.md
Normal file
43
packages/x402/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @dbis-thirdweb/x402
|
||||
|
||||
x402 payment primitives and pay-to-access flows for Chain 138.
|
||||
|
||||
## Usage
|
||||
|
||||
### Pay-to-Access Flow
|
||||
|
||||
```typescript
|
||||
import { PayToAccessFlow, InMemoryReplayProtectionStore } from '@dbis-thirdweb/x402';
|
||||
import { ThirdwebSDK } from '@thirdweb-dev/sdk';
|
||||
import { chain138 } from '@dbis-thirdweb/chain';
|
||||
|
||||
// Server-side: Create request
|
||||
const sdk = new ThirdwebSDK(chain138);
|
||||
const provider = sdk.getProvider();
|
||||
const replayStore = new InMemoryReplayProtectionStore();
|
||||
|
||||
const flow = new PayToAccessFlow(provider, replayStore);
|
||||
|
||||
const request = await flow.createRequest({
|
||||
amount: ethers.utils.parseEther('0.01'),
|
||||
recipient: '0x...',
|
||||
expiresInSeconds: 3600,
|
||||
});
|
||||
|
||||
const challenge = flow.generateChallenge(request);
|
||||
|
||||
// Client-side: Fulfill payment
|
||||
const signer = await wallet.getSigner();
|
||||
const receipt = await flow.fulfillPayment(challenge, signer);
|
||||
|
||||
// Server-side: Verify payment
|
||||
const isValid = await flow.verifyPayment(receipt, request);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Payment request creation and validation
|
||||
- Replay protection via request ID tracking
|
||||
- Receipt verification on-chain
|
||||
- Pay-to-access flow implementation
|
||||
- Expiration handling
|
||||
38
packages/x402/package.json
Normal file
38
packages/x402/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@dbis-thirdweb/x402",
|
||||
"version": "0.1.0",
|
||||
"description": "x402 payment primitives and pay-to-access flows for Chain 138",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||
"lint": "eslint src",
|
||||
"test": "echo \"No tests yet\""
|
||||
},
|
||||
"keywords": [
|
||||
"thirdweb",
|
||||
"x402",
|
||||
"payment",
|
||||
"chain-138"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dbis-thirdweb/chain": "workspace:*",
|
||||
"@thirdweb-dev/sdk": "^4.0.0",
|
||||
"ethers": "^5.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
118
packages/x402/src/flows/payToAccess.ts
Normal file
118
packages/x402/src/flows/payToAccess.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Signer, providers } from 'ethers';
|
||||
import { ethers } from 'ethers';
|
||||
import type { PaymentRequest, PaymentChallenge, PaymentReceipt } from '../types';
|
||||
import { validateRequest, type ReplayProtectionStore } from '../replayProtection';
|
||||
import { verifyReceipt, waitForReceipt } from '../receiptVerification';
|
||||
import { chain138 } from '@dbis-thirdweb/chain';
|
||||
|
||||
/**
|
||||
* Generate payment challenge for x402 pay-to-access flow
|
||||
*/
|
||||
export function generateChallenge(request: PaymentRequest): PaymentChallenge {
|
||||
const nonce = ethers.utils.hexlify(ethers.utils.randomBytes(32));
|
||||
|
||||
// Create message to sign (includes request details + nonce for replay protection)
|
||||
const message = JSON.stringify({
|
||||
requestId: request.requestId,
|
||||
amount: request.amount.toString(),
|
||||
recipient: request.recipient,
|
||||
expiresAt: request.expiresAt,
|
||||
nonce,
|
||||
chainId: chain138.chainId,
|
||||
});
|
||||
|
||||
return {
|
||||
request,
|
||||
nonce,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* x402 Pay-to-Access Flow
|
||||
* HTTP request → x402 challenge → settlement on-chain
|
||||
*/
|
||||
export class PayToAccessFlow {
|
||||
constructor(
|
||||
private provider: providers.Provider,
|
||||
private replayStore: ReplayProtectionStore
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a payment request (server-side)
|
||||
*/
|
||||
async createRequest(params: {
|
||||
amount: bigint;
|
||||
recipient: string;
|
||||
expiresInSeconds?: number;
|
||||
metadata?: string;
|
||||
}): Promise<PaymentRequest> {
|
||||
const requestId = ethers.utils.hexlify(ethers.utils.randomBytes(32));
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + (params.expiresInSeconds || 3600);
|
||||
|
||||
const request: PaymentRequest = {
|
||||
requestId,
|
||||
amount: params.amount,
|
||||
recipient: params.recipient,
|
||||
expiresAt,
|
||||
metadata: params.metadata,
|
||||
};
|
||||
|
||||
// Validate request (check for expiration and replay)
|
||||
await validateRequest(request, this.replayStore);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate challenge from request (server-side)
|
||||
*/
|
||||
generateChallenge(request: PaymentRequest): PaymentChallenge {
|
||||
return generateChallenge(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fulfill payment (client-side with signer)
|
||||
*/
|
||||
async fulfillPayment(
|
||||
challenge: PaymentChallenge,
|
||||
signer: Signer
|
||||
): Promise<PaymentReceipt> {
|
||||
// Validate request before processing
|
||||
await validateRequest(challenge.request, this.replayStore);
|
||||
|
||||
// Create and send transaction
|
||||
const tx = await signer.sendTransaction({
|
||||
to: challenge.request.recipient,
|
||||
value: challenge.request.amount,
|
||||
});
|
||||
|
||||
// Wait for confirmation
|
||||
const receipt = await waitForReceipt(tx.hash, this.provider);
|
||||
|
||||
// Mark request as used to prevent replay
|
||||
await this.replayStore.markAsUsed(challenge.request.requestId);
|
||||
|
||||
return {
|
||||
...receipt,
|
||||
requestId: challenge.request.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify payment receipt (server-side)
|
||||
*/
|
||||
async verifyPayment(
|
||||
receipt: PaymentReceipt,
|
||||
originalRequest: PaymentRequest
|
||||
): Promise<boolean> {
|
||||
const isValid = await verifyReceipt(receipt, originalRequest, this.provider);
|
||||
|
||||
if (isValid) {
|
||||
// Mark as used if verification succeeds
|
||||
await this.replayStore.markAsUsed(originalRequest.requestId);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
4
packages/x402/src/index.ts
Normal file
4
packages/x402/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './replayProtection';
|
||||
export * from './receiptVerification';
|
||||
export * from './flows/payToAccess';
|
||||
72
packages/x402/src/receiptVerification.ts
Normal file
72
packages/x402/src/receiptVerification.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { providers } from 'ethers';
|
||||
import type { PaymentReceipt, PaymentRequest } from './types';
|
||||
import { chain138 } from '@dbis-thirdweb/chain';
|
||||
|
||||
/**
|
||||
* Verify payment receipt on-chain
|
||||
*/
|
||||
export async function verifyReceipt(
|
||||
receipt: PaymentReceipt,
|
||||
request: PaymentRequest,
|
||||
provider: providers.Provider
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Get transaction receipt
|
||||
const txReceipt = await provider.getTransactionReceipt(receipt.txHash);
|
||||
|
||||
if (!txReceipt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify transaction is on correct chain
|
||||
// Note: Provider should already be configured for Chain 138
|
||||
|
||||
// Verify transaction succeeded
|
||||
if (txReceipt.status !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify block number matches
|
||||
if (txReceipt.blockNumber !== receipt.blockNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify amount was transferred (check logs or transaction value)
|
||||
// This is a simplified check - in production, you'd verify specific logs/events
|
||||
const tx = await provider.getTransaction(receipt.txHash);
|
||||
if (tx && tx.value.toString() !== request.amount.toString()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error verifying receipt:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for payment receipt confirmation
|
||||
*/
|
||||
export async function waitForReceipt(
|
||||
txHash: string,
|
||||
provider: providers.Provider,
|
||||
confirmations: number = 1
|
||||
): Promise<PaymentReceipt> {
|
||||
const receipt = await provider.waitForTransaction(txHash, confirmations);
|
||||
|
||||
if (!receipt) {
|
||||
throw new Error(`Transaction ${txHash} not found`);
|
||||
}
|
||||
|
||||
if (receipt.status !== 1) {
|
||||
throw new Error(`Transaction ${txHash} failed`);
|
||||
}
|
||||
|
||||
return {
|
||||
txHash: receipt.transactionHash,
|
||||
blockNumber: receipt.blockNumber,
|
||||
requestId: '', // Should be extracted from transaction data/logs
|
||||
confirmedAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
50
packages/x402/src/replayProtection.ts
Normal file
50
packages/x402/src/replayProtection.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { PaymentRequest } from './types';
|
||||
|
||||
/**
|
||||
* Storage interface for replay protection
|
||||
*/
|
||||
export interface ReplayProtectionStore {
|
||||
hasBeenUsed(requestId: string): Promise<boolean>;
|
||||
markAsUsed(requestId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory replay protection store (for testing/single-instance use)
|
||||
*/
|
||||
export class InMemoryReplayProtectionStore implements ReplayProtectionStore {
|
||||
private usedRequests = new Set<string>();
|
||||
|
||||
async hasBeenUsed(requestId: string): Promise<boolean> {
|
||||
return this.usedRequests.has(requestId);
|
||||
}
|
||||
|
||||
async markAsUsed(requestId: string): Promise<void> {
|
||||
this.usedRequests.add(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if payment request has expired
|
||||
*/
|
||||
export function isRequestExpired(request: PaymentRequest): boolean {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return request.expiresAt < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment request for replay protection
|
||||
*/
|
||||
export async function validateRequest(
|
||||
request: PaymentRequest,
|
||||
store: ReplayProtectionStore
|
||||
): Promise<void> {
|
||||
// Check expiration
|
||||
if (isRequestExpired(request)) {
|
||||
throw new Error(`Payment request ${request.requestId} has expired`);
|
||||
}
|
||||
|
||||
// Check if already used
|
||||
if (await store.hasBeenUsed(request.requestId)) {
|
||||
throw new Error(`Payment request ${request.requestId} has already been used`);
|
||||
}
|
||||
}
|
||||
76
packages/x402/src/types.ts
Normal file
76
packages/x402/src/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { BigNumberish } from 'ethers';
|
||||
|
||||
/**
|
||||
* x402 Payment Request
|
||||
*/
|
||||
export interface PaymentRequest {
|
||||
/**
|
||||
* Unique request ID (prevents replay attacks)
|
||||
*/
|
||||
requestId: string;
|
||||
|
||||
/**
|
||||
* Payment amount in native currency (wei)
|
||||
*/
|
||||
amount: BigNumberish;
|
||||
|
||||
/**
|
||||
* Recipient address
|
||||
*/
|
||||
recipient: string;
|
||||
|
||||
/**
|
||||
* Timestamp when request expires (Unix timestamp in seconds)
|
||||
*/
|
||||
expiresAt: number;
|
||||
|
||||
/**
|
||||
* Optional metadata or description
|
||||
*/
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* x402 Payment Challenge
|
||||
*/
|
||||
export interface PaymentChallenge {
|
||||
/**
|
||||
* Original request
|
||||
*/
|
||||
request: PaymentRequest;
|
||||
|
||||
/**
|
||||
* Nonce for replay protection
|
||||
*/
|
||||
nonce: string;
|
||||
|
||||
/**
|
||||
* Challenge message to sign
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* x402 Payment Receipt
|
||||
*/
|
||||
export interface PaymentReceipt {
|
||||
/**
|
||||
* Transaction hash
|
||||
*/
|
||||
txHash: string;
|
||||
|
||||
/**
|
||||
* Block number where transaction was confirmed
|
||||
*/
|
||||
blockNumber: number;
|
||||
|
||||
/**
|
||||
* Request ID that was fulfilled
|
||||
*/
|
||||
requestId: string;
|
||||
|
||||
/**
|
||||
* Timestamp of confirmation
|
||||
*/
|
||||
confirmedAt: number;
|
||||
}
|
||||
12
packages/x402/tsconfig.json
Normal file
12
packages/x402/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
{ "path": "../chain" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user