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
135 changes: 107 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,65 +290,130 @@ npm publish --access public
MIT © AceDataCloud
# @acedatacloud/x402-client

X402 payment protocol client for AceDataCloud APIs. It wraps the standard `402 Payment Required` flow:
X402 payment protocol client for AceDataCloud APIs. It is designed to
plug into [`@acedatacloud/sdk`](https://github.com/AceDataCloud/SDK) —
the official SDK does all the API work, and this package only
contributes the part the SDK can't do by itself: signing an `X-Payment`
header when the server returns `402 Payment Required`.

1. send the API request
2. parse the returned payment requirement
3. sign with the configured wallet
4. retry with `X-Payment`

Currently verified live against `https://api.acedata.cloud/openai/chat/completions` on `base`, `solana`, and `skale`.
Currently verified live on `base`, `solana`, and `skale` against
`https://api.acedata.cloud`.

## Install

After the first npm release:

```bash
npm install @acedatacloud/x402-client
npm install @acedatacloud/sdk @acedatacloud/x402-client
# Solana support:
npm install @solana/web3.js
```

If `npm install` still returns `404`, the package has not been released to npm yet. In that case, use the GitHub source temporarily:
If the npm package has not been released yet, you can install directly
from GitHub:

```bash
npm install github:AceDataCloud/X402Client
```

## Usage
## Recommended Usage: Plug Into the SDK

### Solana
The SDK already knows how to call every AceDataCloud endpoint
(`openai.chat`, `images`, `audio`, `video`, …). To pay for those
calls with x402 instead of a Bearer token, just pass a
`paymentHandler` produced by this package:

```ts
import { createX402Client } from '@acedatacloud/x402-client';
### EVM (Base / SKALE)

const client = createX402Client({
baseURL: 'https://api.acedata.cloud',
network: 'solana',
solanaWallet: phantomWallet,
```ts
import { AceDataCloud } from '@acedatacloud/sdk';
import { createX402PaymentHandler } from '@acedatacloud/x402-client';

const client = new AceDataCloud({
// No apiToken — per-request on-chain payment.
paymentHandler: createX402PaymentHandler({
network: 'base', // or 'skale'
evmProvider: window.ethereum,
evmAddress: '0xYourAddress...',
}),
});

const result = await client.post('/openai/chat/completions', {
const res = await client.openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Say hi in 3 words' }],
max_tokens: 10,
});
console.log(res.choices[0].message.content);
```

### Solana

```ts
import { AceDataCloud } from '@acedatacloud/sdk';
import { createX402PaymentHandler } from '@acedatacloud/x402-client';

const client = new AceDataCloud({
paymentHandler: createX402PaymentHandler({
network: 'solana',
solanaWallet: phantomWallet,
}),
});

const task = await client.images.generate({ prompt: 'a sunset' });
const result = await task.wait();
```

console.log(result.status);
console.log(result.paid);
console.log(result.data);
On every request the SDK first sends the call unauthenticated. If the
server returns `402`, it passes the `accepts` list to the handler,
which signs and returns the `X-Payment` header. The SDK retries once
with that header. Task polling, streaming, retries, and error mapping
all keep working — this is a one-line swap from Bearer auth.

### Bootstrapping from env

A typical Node process picks up its wallet from environment:

```ts
import 'dotenv/config';
import { JsonRpcProvider, Wallet } from 'ethers';
import { AceDataCloud } from '@acedatacloud/sdk';
import { createX402PaymentHandler } from '@acedatacloud/x402-client';

const wallet = new Wallet(process.env.EVM_PRIVATE_KEY!, new JsonRpcProvider(process.env.BASE_RPC));

const client = new AceDataCloud({
paymentHandler: createX402PaymentHandler({
network: 'base',
// Any EIP-1193-compatible provider works.
evmProvider: {
request: async ({ method, params }) => {
if (method === 'eth_signTypedData_v4') {
const [, typed] = params as [string, string];
return wallet.signTypedData(
JSON.parse(typed).domain,
JSON.parse(typed).types,
JSON.parse(typed).message,
);
}
throw new Error(`unsupported: ${method}`);
},
},
evmAddress: wallet.address,
}),
});
```

### Base / SKALE
## Low-Level Client (No SDK)

If you don't want to use the SDK and just need to send a single
x402-authenticated request, the package also exposes a stand-alone
`createX402Client` that wraps `fetch`:

```ts
import { createX402Client } from '@acedatacloud/x402-client';

const client = createX402Client({
baseURL: 'https://api.acedata.cloud',
network: 'base', // or 'skale'
evmProvider: window.ethereum,
evmAddress: '0xYourAddress...',
network: 'solana',
solanaWallet: phantomWallet,
});

const result = await client.post('/openai/chat/completions', {
Expand All @@ -358,7 +423,12 @@ const result = await client.post('/openai/chat/completions', {
});
```

### Low-level signing
This exists mainly for quick experiments and the low-level e2e
scripts. For production integrations, prefer the SDK path above — you
get task polling, SSE streaming, retries, typed errors, and coverage
for every AceDataCloud endpoint for free.

## Low-level signing

```ts
import { signSolanaPayment, signEVMPayment } from '@acedatacloud/x402-client';
Expand Down Expand Up @@ -419,6 +489,15 @@ const result = await client.post('/suno/audios', {

If an endpoint does **not** return `402`, it is not currently using x402 payment flow and should be called with its normal auth path instead.

## Python

There is currently no Python x402 signer. The Python SDK
(`acedatacloud`) already exposes the same `payment_handler` hook as
the TypeScript SDK — any callable that returns
`{"headers": {"X-Payment": "<base64>"}}` works. A Python port of the
signing logic in this package is tracked as future work; contributions
are welcome.

## Live Verification

The repository includes a three-network regression script:
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./sdk": {
"import": "./dist/sdkAdapter.js",
"types": "./dist/sdkAdapter.d.ts"
},
"./solana": {
"import": "./dist/solana.js",
"types": "./dist/solana.d.ts"
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export { createX402Client } from './client.js';
export type { X402Client } from './client.js';
export {
createX402PaymentHandler,
} from './sdkAdapter.js';
export type { X402PaymentHandlerOptions } from './sdkAdapter.js';
export { signSolanaPayment } from './solana.js';
export { signEVMPayment } from './evm.js';
export type {
Expand Down
104 changes: 104 additions & 0 deletions src/sdkAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* SDK adapter: turn X402 wallet configuration into a `PaymentHandler`
* compatible with `@acedatacloud/sdk`.
*
* Usage:
*
* import { AceDataCloud } from '@acedatacloud/sdk';
* import { createX402PaymentHandler } from '@acedatacloud/x402-client';
*
* const client = new AceDataCloud({
* paymentHandler: createX402PaymentHandler({
* network: 'base',
* evmProvider: window.ethereum,
* evmAddress: '0x...',
* }),
* });
*
* // Normal SDK calls work — no Bearer token needed.
* await client.openai.chat.completions.create({ ... });
*/

import { signEVMPayment } from './evm.js';
import { signSolanaPayment } from './solana.js';
import type {
EVMProvider,
PaymentRequirement,
SolanaWalletAdapter,
X402PaymentEnvelope,
} from './types.js';

/** Options for `createX402PaymentHandler`. */
export interface X402PaymentHandlerOptions {
/** Preferred payment network. */
network: 'solana' | 'base' | 'skale';
/** Solana wallet adapter (required if network=solana). */
solanaWallet?: SolanaWalletAdapter;
/** EVM EIP-1193 provider (required if network=base/skale). */
evmProvider?: EVMProvider;
/** EVM account address (required if network=base/skale). */
evmAddress?: string;
}

/** Minimal shape of the SDK's PaymentHandler context. Duplicated here
* so this package does not need a dependency on `@acedatacloud/sdk`. */
interface SdkPaymentHandlerContext {
url: string;
method: string;
body?: unknown;
accepts: PaymentRequirement[];
}

interface SdkPaymentHandlerResult {
headers: Record<string, string>;
}

function encodePaymentHeader(envelope: X402PaymentEnvelope): string {
// Browser-first; fall back to Buffer in Node.
if (typeof btoa === 'function') {
return btoa(JSON.stringify(envelope));
}
return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64');
}

function selectRequirement(
accepts: PaymentRequirement[],
network: string
): PaymentRequirement | undefined {
return accepts.find((r) => r.network === network);
}

/**
* Build a `PaymentHandler` function that `@acedatacloud/sdk` can invoke
* when the API returns `402 Payment Required`.
*/
export function createX402PaymentHandler(
options: X402PaymentHandlerOptions
): (ctx: SdkPaymentHandlerContext) => Promise<SdkPaymentHandlerResult> {
const { network, solanaWallet, evmProvider, evmAddress } = options;

if (network === 'solana' && !solanaWallet) {
throw new Error('solanaWallet is required when network="solana"');
}
if ((network === 'base' || network === 'skale') && (!evmProvider || !evmAddress)) {
throw new Error('evmProvider and evmAddress are required for EVM networks');
}

return async function x402PaymentHandler(ctx: SdkPaymentHandlerContext) {
const requirement = selectRequirement(ctx.accepts, network);
if (!requirement) {
const available = ctx.accepts.map((a) => a.network).join(', ') || '<none>';
throw new Error(
`X402: no payment requirement for network "${network}". Available: ${available}`
);
}

let envelope: X402PaymentEnvelope;
if (network === 'solana') {
envelope = await signSolanaPayment(requirement, solanaWallet!);
} else {
envelope = await signEVMPayment(requirement, evmProvider!, evmAddress!);
}
return { headers: { 'X-Payment': encodePaymentHeader(envelope) } };
};
}
Loading