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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,8 @@
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"dhali-js": "^3.0.4"
}
Comment on lines +95 to 97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check npm registry for package versions
echo "=== dhali-js versions ==="
npm view dhali-js versions --json 2>/dev/null | jq -r '.[-5:]' || echo "Package not found or error"

echo -e "\n=== xrpl latest version ==="
npm view xrpl version 2>/dev/null || echo "Package not found or error"

Repository: BlockRunAI/ClawRouter

Length of output: 183


🏁 Script executed:

cat src/dhali.ts

Repository: BlockRunAI/ClawRouter

Length of output: 4242


🏁 Script executed:

grep -n "xrpl" package.json

Repository: BlockRunAI/ClawRouter

Length of output: 47


🏁 Script executed:

sed -n '90,100p' package.json

Repository: BlockRunAI/ClawRouter

Length of output: 191


Add xrpl to optionalDependencies.

Both dhali-js and xrpl are dynamically imported in src/dhali.ts for XRPL network support, but only dhali-js is listed as optional. Add xrpl for consistency.

Proposed fix
  "optionalDependencies": {
    "dhali-js": "^3.0.4",
+   "xrpl": "^2.0.0"
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 95 - 97, Add "xrpl" to the optionalDependencies
object in package.json alongside "dhali-js" so the dynamic import in
src/dhali.ts can succeed when XRPL support is present; update the
optionalDependencies block to include an appropriate version specifier for
"xrpl" (e.g., a caret version matching your project's supported release) so both
"dhali-js" and "xrpl" are declared as optional.

}
101 changes: 101 additions & 0 deletions src/dhali.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> | null = null;

export async function createDhaliPayment(
privateKey: string,
amount: string,
option: PaymentOption,
paymentHeader?: string
): Promise<string> {
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();
}
Comment on lines +16 to +20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add timeout to config fetch to prevent indefinite hangs.

The fetch to the remote config URL has no timeout. If the GitHub raw content server is slow or unresponsive, this could block indefinitely.

Proposed fix using AbortController
     if (!cachedDhaliConfig) {
-        const response = await fetch("https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json");
+        const controller = new AbortController();
+        const timeoutId = setTimeout(() => controller.abort(), 10000);
+        try {
+            const response = await fetch(
+                "https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json",
+                { signal: controller.signal }
+            );
+            if (!response.ok) throw new Error("Failed to fetch Dhali public config");
+            cachedDhaliConfig = await response.json();
+        } finally {
+            clearTimeout(timeoutId);
+        }
-        if (!response.ok) throw new Error("Failed to fetch Dhali public config");
-        cachedDhaliConfig = await response.json();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/dhali.ts` around lines 16 - 20, The fetch of the Dhali public config (the
block that assigns cachedDhaliConfig when !cachedDhaliConfig) lacks a timeout
and can hang; update the fetch call to use an AbortController with a short
timeout (e.g. 3–10s) that calls controller.abort() via setTimeout, pass
controller.signal to fetch, clear the timeout on success, and catch the abort
error to throw a clear error like "Timed out fetching Dhali public config" so
cachedDhaliConfig handling remains robust.


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);
Comment on lines +54 to +59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate details.scale and details.issuer before use.

If the Dhali config has incomplete currency metadata, accessing details.scale or details.issuer could pass undefined to the Currency constructor, potentially causing runtime errors or unexpected behavior.

Proposed validation
     if (!result) {
         throw new Error(`Dhali configuration missing currency metadata for asset: ${option.asset} on protocol: ${protocol}`);
     }
     const [symbol, details] = result;
+    if (details.scale === undefined) {
+        throw new Error(`Dhali configuration missing scale for asset: ${option.asset}`);
+    }

     const currencyObj = new Currency(protocol, symbol, details.scale, details.issuer);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/dhali.ts` around lines 54 - 59, The code reads result = ... then does
const [symbol, details] = result and constructs new Currency(protocol, symbol,
details.scale, details.issuer) without verifying details properties; add
explicit validation for details.scale and details.issuer (e.g., check typeof
details === 'object' and that details.scale and details.issuer are defined and
of expected types/values) before calling the Currency constructor, and if
validation fails throw a clear Error mentioning the asset and protocol (same
context as the existing missing-config error) so undefined values are never
passed into Currency.


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);
}
96 changes: 69 additions & 27 deletions src/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -40,7 +41,7 @@ function createNonce(): `0x${string}` {
.join("")}` as `0x${string}`;
}

interface PaymentOption {
export interface PaymentOption {
scheme: string;
network: string;
amount?: string;
Expand All @@ -51,7 +52,7 @@ interface PaymentOption {
extra?: { name?: string; version?: string };
}

interface PaymentRequired {
export interface PaymentRequired {
accepts: PaymentOption[];
resource?: { url?: string; description?: string };
}
Expand Down Expand Up @@ -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<string> {
const { address: fromAddress, privateKey: evmPrivateKey } = await getAccount(privateKey);
const network = normalizeNetwork(option.network);
const chainId = resolveChainId(network);
const recipient = requireHexAddress(option.payTo, "payTo");
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 (
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down