From 84b5af0857eca0b6c83e76400cf0b6e5d3558db1 Mon Sep 17 00:00:00 2001 From: David Simmons Date: Mon, 9 Mar 2026 21:11:04 +0000 Subject: [PATCH] (feat) Support Dhali payment channel x402 payments --- package.json | 3 ++ src/dhali.ts | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/x402.ts | 96 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 src/dhali.ts diff --git a/package.json b/package.json index bca52fc..f745ec3 100644 --- a/package.json +++ b/package.json @@ -91,5 +91,8 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "dhali-js": "^3.0.4" } } diff --git a/src/dhali.ts b/src/dhali.ts new file mode 100644 index 0000000..5cd1eff --- /dev/null +++ b/src/dhali.ts @@ -0,0 +1,101 @@ +import { PaymentOption } from "./x402.js"; +import { createWalletClient, createPublicClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let cachedDhaliConfig: Record | null = null; + +export async function createDhaliPayment( + privateKey: string, + amount: string, + option: PaymentOption, + paymentHeader?: string +): Promise { + const networkCode = option.network.toLowerCase(); + + if (!cachedDhaliConfig) { + const response = await fetch("https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json"); + if (!response.ok) throw new Error("Failed to fetch Dhali public config"); + cachedDhaliConfig = await response.json(); + } + + const [protocol] = Object.entries(cachedDhaliConfig?.CAIP_2_MAPPINGS || {}).find( + ([, c]) => typeof c === "string" && c.toLowerCase() === networkCode + ) || []; + + if (!protocol) { + throw new Error(`Dhali configuration missing protocol mapping for CAIP-2 network: ${option.network}`); + } + + const isXrpl = protocol.startsWith("XRPL") || protocol.startsWith("XAHL"); + const isEthereum = protocol === "ETHEREUM" || protocol === "SEPOLIA"; + + const endpointUrl = cachedDhaliConfig?.PUBLIC_CLIENTS?.[protocol]?.HTTP_CLIENT; + if (!endpointUrl) throw new Error(`Missing endpoint for protocol ${protocol}`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let dhaliObj: any; + try { + // @ts-expect-error - dhali-js is not typed + dhaliObj = await import("dhali-js"); + } catch { + throw new Error("dhali-js is required for Dhali payments. please 'npm install dhali-js'."); + } + const { DhaliChannelManager, wrapAsX402PaymentPayload, Currency } = dhaliObj.default || dhaliObj; + + const currencyMetadata = cachedDhaliConfig?.DHALI_PUBLIC_ADDRESSES?.[protocol] || {}; + const assetLower = option.asset.toLowerCase(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (Object.entries(currencyMetadata) as [string, any][]).find( + ([, d]) => d.caip19?.toLowerCase() === assetLower + ); + + if (!result) { + throw new Error(`Dhali configuration missing currency metadata for asset: ${option.asset} on protocol: ${protocol}`); + } + const [symbol, details] = result; + + const currencyObj = new Currency(protocol, symbol, details.scale, details.issuer); + + let claim: string; + if (isEthereum) { + const finalPriv = (privateKey.toLowerCase().startsWith("0x") ? privateKey : `0x${privateKey}`) as `0x${string}`; + const account = privateKeyToAccount(finalPriv); + const publicClient = createPublicClient({ transport: http(endpointUrl) }); + const walletClient = createWalletClient({ account, transport: http(endpointUrl) }); + const manager = DhaliChannelManager.evm(walletClient, publicClient, currencyObj); + claim = await manager.getAuthToken(amount); + } else if (isXrpl) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let xrpl: any; + try { + xrpl = await import("xrpl"); + } catch { + throw new Error("xrpl is required for XRPL payments. please 'npm install xrpl'."); + } + + const wallet = xrpl.Wallet.fromSeed(privateKey); + const client = new xrpl.Client(endpointUrl); + await client.connect(); + try { + const manager = DhaliChannelManager.xrpl(wallet, client, currencyObj); + claim = await manager.getAuthToken(amount); + } finally { + await client.disconnect(); + } + } else { + throw new Error(`Unsupported network: ${option.network}`); + } + + const headerToWrap = paymentHeader || Buffer.from(JSON.stringify({ + scheme: option.scheme, + network: option.network, + asset: option.asset, + payTo: option.payTo, + amount: amount.toString(), + maxTimeoutSeconds: option.maxTimeoutSeconds, + extra: option.extra || {}, + })).toString("base64"); + return wrapAsX402PaymentPayload(claim, headerToWrap); +} diff --git a/src/x402.ts b/src/x402.ts index 2d0376c..6fd7fe8 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -11,6 +11,7 @@ * - Falls back to normal 402 flow if pre-signed payment is rejected. */ +import { createDhaliPayment } from "./dhali.js"; import { signTypedData, privateKeyToAccount } from "viem/accounts"; import { PaymentCache } from "./payment-cache.js"; @@ -40,7 +41,7 @@ function createNonce(): `0x${string}` { .join("")}` as `0x${string}`; } -interface PaymentOption { +export interface PaymentOption { scheme: string; network: string; amount?: string; @@ -51,7 +52,7 @@ interface PaymentOption { extra?: { name?: string; version?: string }; } -interface PaymentRequired { +export interface PaymentRequired { accepts: PaymentOption[]; resource?: { url?: string; description?: string }; } @@ -124,14 +125,20 @@ function setPaymentHeaders(headers: Headers, payload: string): void { headers.set("x-payment", payload); } +async function getAccount(privateKey: string): Promise<{ address: `0x${string}`; privateKey: `0x${string}` }> { + const priv = (privateKey.toLowerCase().startsWith("0x") ? privateKey : `0x${privateKey}`) as `0x${string}`; + const account = privateKeyToAccount(priv); + return { address: account.address, privateKey: priv }; +} + async function createPaymentPayload( - privateKey: `0x${string}`, - fromAddress: string, + privateKey: string, option: PaymentOption, amount: string, requestUrl: string, resource: PaymentRequired["resource"], ): Promise { + const { address: fromAddress, privateKey: evmPrivateKey } = await getAccount(privateKey); const network = normalizeNetwork(option.network); const chainId = resolveChainId(network); const recipient = requireHexAddress(option.payTo, "payTo"); @@ -148,7 +155,7 @@ async function createPaymentPayload( const nonce = createNonce(); const signature = await signTypedData({ - privateKey, + privateKey: evmPrivateKey, domain: { name: option.extra?.name || DEFAULT_TOKEN_NAME, version: option.extra?.version || DEFAULT_TOKEN_VERSION, @@ -200,6 +207,8 @@ async function createPaymentPayload( return encodeBase64Json(paymentData); } + + /** Pre-auth parameters for skipping the 402 round trip. */ export type PreAuthParams = { estimatedAmount: string; // USDC amount in smallest unit (6 decimals) @@ -222,9 +231,7 @@ export type PaymentFetchResult = { * pre-signs and attaches payment to the first request, skipping the 402 round trip. * Falls back to normal 402 flow if pre-signed payment is rejected. */ -export function createPaymentFetch(privateKey: `0x${string}`): PaymentFetchResult { - const account = privateKeyToAccount(privateKey); - const walletAddress = account.address; +export function createPaymentFetch(privateKey: string): PaymentFetchResult { const paymentCache = new PaymentCache(); const payFetch = async ( @@ -238,24 +245,41 @@ export function createPaymentFetch(privateKey: `0x${string}`): PaymentFetchResul // --- Pre-auth path: skip 402 round trip --- const cached = paymentCache.get(endpointPath); if (cached && preAuth?.estimatedAmount) { - const paymentPayload = await createPaymentPayload( - privateKey, - walletAddress, - { - scheme: cached.scheme, - network: cached.network, - asset: cached.asset, - payTo: cached.payTo, - maxTimeoutSeconds: cached.maxTimeoutSeconds, - extra: cached.extra, - }, - preAuth.estimatedAmount, - url, - { - url: cached.resourceUrl, - description: cached.resourceDescription, - }, - ); + const amount = preAuth.estimatedAmount; + let paymentPayload: string; + + if (cached.scheme === "dhali") { + paymentPayload = await createDhaliPayment( + privateKey as `0x${string}`, + amount, + { + scheme: cached.scheme, + network: cached.network, + asset: cached.asset, + payTo: cached.payTo, + maxTimeoutSeconds: cached.maxTimeoutSeconds, + extra: cached.extra, + } + ); + } else { + paymentPayload = await createPaymentPayload( + privateKey, + { + scheme: cached.scheme, + network: cached.network, + asset: cached.asset, + payTo: cached.payTo, + maxTimeoutSeconds: cached.maxTimeoutSeconds, + extra: cached.extra, + }, + amount, + url, + { + url: cached.resourceUrl, + description: cached.resourceDescription, + }, + ); + } const preAuthHeaders = new Headers(init?.headers); setPaymentHeaders(preAuthHeaders, paymentPayload); @@ -334,10 +358,28 @@ export function createPaymentFetch(privateKey: `0x${string}`): PaymentFetchResul resourceDescription: paymentRequired.resource?.description, }); + // --- Dhali Scheme Support --- + if (option.scheme === "dhali") { + const dhaliPaymentPayload = await createDhaliPayment( + privateKey, + amount, + option, + paymentHeader + ); + + const dhaliRetryHeaders = new Headers(init?.headers); + setPaymentHeaders(dhaliRetryHeaders, dhaliPaymentPayload); + + return fetch(input, { + ...init, + headers: dhaliRetryHeaders, + }); + } + + // --- Standard x402 Scheme (default) --- // Create signed payment const paymentPayload = await createPaymentPayload( privateKey, - walletAddress, option, amount, url,