@coin-voyage/paykit-headless 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/x402/agent.d.ts +47 -0
- package/dist/x402/agent.js +383 -0
- package/dist/x402/core.d.ts +61 -0
- package/dist/x402/core.js +69 -0
- package/dist/x402/index.d.ts +3 -0
- package/dist/x402/index.js +3 -0
- package/dist/x402/sui.d.ts +60 -0
- package/dist/x402/sui.js +308 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @coin-voyage/paykit-headless
|
|
2
|
+
|
|
3
|
+
Headless helpers for CoinVoyage PayKit and x402 payments.
|
|
4
|
+
|
|
5
|
+
## x402 Agent Payments
|
|
6
|
+
|
|
7
|
+
Install the package in the agent runtime:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @coin-voyage/paykit-headless
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use `executeX402AgentPayment` when the agent should fetch a `PAYMENT-REQUIRED` challenge, create a
|
|
14
|
+
`PAYMENT-SIGNATURE`, and retry the protected resource:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { executeX402AgentPayment } from "@coin-voyage/paykit-headless/x402"
|
|
18
|
+
|
|
19
|
+
const result = await executeX402AgentPayment({
|
|
20
|
+
url:
|
|
21
|
+
"https://example.com/api/agent/payment-required" +
|
|
22
|
+
"?preferred_chain_type=SUI" +
|
|
23
|
+
"&preferred_chain_id=30000000000002" +
|
|
24
|
+
"&preferred_token_address=0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC",
|
|
25
|
+
network: "sui:mainnet",
|
|
26
|
+
asset: "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC",
|
|
27
|
+
maxAmount: "20000",
|
|
28
|
+
chainType: "SUI",
|
|
29
|
+
privateKey: process.env.X402_AGENT_SUI_PRIVATE_KEY,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (!result.ok) {
|
|
33
|
+
throw new Error(result.message)
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Do not request `?preferred_chain_type=SUI` by itself. If you send an `X402RequirementRequest`, include
|
|
38
|
+
`preferred_chain_type` and `preferred_token_address` together. If you do not have a preferred token,
|
|
39
|
+
omit all preference fields and choose from the returned `accepts[]` entries.
|
|
40
|
+
|
|
41
|
+
For a challenge that was already fetched, pass the `PAYMENT-REQUIRED` header:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createSuiX402PaymentSignatureHeader } from "@coin-voyage/paykit-headless/x402"
|
|
45
|
+
|
|
46
|
+
const { paymentSignature } = await createSuiX402PaymentSignatureHeader(paymentRequiredHeader, {
|
|
47
|
+
network: "sui:mainnet",
|
|
48
|
+
asset: "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC",
|
|
49
|
+
maxAmount: "20000",
|
|
50
|
+
privateKey: process.env.X402_AGENT_SUI_PRIVATE_KEY,
|
|
51
|
+
rpcUrl: process.env.X402_AGENT_SUI_RPC_URL,
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Sui Payload Contract
|
|
56
|
+
|
|
57
|
+
Sui x402 support uses a CoinVoyage-specific payload carried in the standard base64
|
|
58
|
+
`PAYMENT-SIGNATURE` header. The package:
|
|
59
|
+
|
|
60
|
+
- Selects the returned `sui:mainnet` `accepts[]` entry without changing `scheme`, `network`,
|
|
61
|
+
`asset`, `amount`, `payTo`, or `paymentIdentifier`.
|
|
62
|
+
- Builds a Sui programmable transaction block that transfers the exact raw `amount` of the exact
|
|
63
|
+
coin type to `payTo`.
|
|
64
|
+
- Signs the transaction with the configured Sui Ed25519 key.
|
|
65
|
+
- Encodes `payload.transaction`, `payload.signatures`, `payload.signature`, and `payload.payer`.
|
|
66
|
+
|
|
67
|
+
If the server provides prepared Sui transaction bytes in `accept.transactionBytes`, `accept.txBytes`,
|
|
68
|
+
`accept.extra.sui.transactionBytes`, or `accept.extra.sui.txBytes`, the package signs those bytes
|
|
69
|
+
instead of constructing a transfer transaction.
|
|
70
|
+
|
|
71
|
+
## Required Keys
|
|
72
|
+
|
|
73
|
+
Use rail-specific private keys when possible:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
X402_AGENT_EVM_PRIVATE_KEY=
|
|
77
|
+
X402_AGENT_SVM_PRIVATE_KEY=
|
|
78
|
+
X402_AGENT_SUI_PRIVATE_KEY=
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Pass the private key that matches the request `chainType`. `X402_AGENT_PRIVATE_KEY` can be used as a
|
|
82
|
+
local fallback, but separate rail-specific keys are safer for agents.
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readResponseBody<TBody = unknown>(response: Response): Promise<TBody | undefined>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function readResponseBody(response) {
|
|
2
|
+
if (response.status === 204 || response.status === 205) {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
const text = await response.text();
|
|
6
|
+
if (!text) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
10
|
+
if (contentType.includes("application/json")) {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
return text;
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type X402PaymentAccept, type X402PreferredChainType } from "./core";
|
|
2
|
+
import { type SuiX402PaymentSignatureDebug } from "./sui";
|
|
3
|
+
export type X402AgentPaymentRequest = {
|
|
4
|
+
url: string;
|
|
5
|
+
paymentRequiredHeader?: string;
|
|
6
|
+
network?: string;
|
|
7
|
+
asset?: string;
|
|
8
|
+
paymentIdentifier?: string;
|
|
9
|
+
maxAmount?: string | bigint;
|
|
10
|
+
privateKey?: string;
|
|
11
|
+
chainType: X402PreferredChainType;
|
|
12
|
+
fetchFn?: typeof fetch;
|
|
13
|
+
};
|
|
14
|
+
export type X402AgentPaymentResult = {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
paymentSignatureCreated?: boolean;
|
|
18
|
+
paymentSignatureDebug?: X402PaymentSignatureDebug;
|
|
19
|
+
settlementTxHash?: string;
|
|
20
|
+
status?: number;
|
|
21
|
+
paymentResponse?: unknown;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
paymentRequired?: unknown;
|
|
24
|
+
};
|
|
25
|
+
export type X402PaymentSignatureDebug = {
|
|
26
|
+
accepted?: {
|
|
27
|
+
network?: string;
|
|
28
|
+
asset?: string;
|
|
29
|
+
amount?: string;
|
|
30
|
+
payTo?: string;
|
|
31
|
+
paymentIdentifier?: string;
|
|
32
|
+
};
|
|
33
|
+
svm?: {
|
|
34
|
+
feePayer?: string;
|
|
35
|
+
tokenPayer?: string;
|
|
36
|
+
signedAddresses: string[];
|
|
37
|
+
missingSignatureAddresses: string[];
|
|
38
|
+
};
|
|
39
|
+
sui?: SuiX402PaymentSignatureDebug["sui"];
|
|
40
|
+
};
|
|
41
|
+
export declare function executeX402AgentPayment(request: X402AgentPaymentRequest): Promise<X402AgentPaymentResult>;
|
|
42
|
+
export declare function selectX402AgentPaymentAccept(accepts: X402PaymentAccept[], requested: {
|
|
43
|
+
chainType?: X402PreferredChainType;
|
|
44
|
+
network?: string;
|
|
45
|
+
asset?: string;
|
|
46
|
+
paymentIdentifier?: string;
|
|
47
|
+
}): X402PaymentAccept | undefined;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { readResponseBody } from "../http";
|
|
2
|
+
import { decodeX402PaymentRequiredHeader, decodeX402PaymentResponseHeader, selectPaymentAccept, X402_PAYMENT_REQUIRED_HEADER, X402_PAYMENT_RESPONSE_HEADER, X402_PAYMENT_SIGNATURE_HEADER, } from "./core";
|
|
3
|
+
import { createSuiPaymentSignatureDebug, createSuiX402PaymentSignatureHeader, ExactSuiScheme, isSuiNetwork, } from "./sui";
|
|
4
|
+
import { base58, base64, hex } from "@scure/base";
|
|
5
|
+
import { createKeyPairSignerFromBytes, createKeyPairSignerFromPrivateKeyBytes } from "@solana/kit";
|
|
6
|
+
import { x402Client } from "@x402/core/client";
|
|
7
|
+
import { encodePaymentSignatureHeader } from "@x402/core/http";
|
|
8
|
+
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
9
|
+
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
10
|
+
import { decodeTransactionFromPayload, getTokenPayerFromTransaction } from "@x402/svm";
|
|
11
|
+
import { ExactSvmScheme } from "@x402/svm/exact/client";
|
|
12
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
13
|
+
export async function executeX402AgentPayment(request) {
|
|
14
|
+
if (request.paymentRequiredHeader) {
|
|
15
|
+
return executePreparedX402AgentPayment(request.url, request);
|
|
16
|
+
}
|
|
17
|
+
if (request.chainType !== "SUI") {
|
|
18
|
+
return executeOfficialX402AgentPayment(request.url, request);
|
|
19
|
+
}
|
|
20
|
+
return executeSuiX402AgentPayment(request.url, request);
|
|
21
|
+
}
|
|
22
|
+
async function executePreparedX402AgentPayment(targetUrl, request) {
|
|
23
|
+
const fetchImpl = request.fetchFn ?? fetch;
|
|
24
|
+
const paymentRequired = decodeX402PaymentRequiredHeader(request.paymentRequiredHeader);
|
|
25
|
+
const selected = selectX402AgentPaymentAccept(paymentRequired.accepts, request);
|
|
26
|
+
if (!selected) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
message: "No compatible x402 payment option found for the selected accepts[] entry.",
|
|
30
|
+
paymentRequired,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const signatureResult = isSuiNetwork(selected.network)
|
|
34
|
+
? await createSuiX402PaymentSignatureHeader(request.paymentRequiredHeader, {
|
|
35
|
+
network: selected.network,
|
|
36
|
+
asset: selected.assetAddress ?? selected.asset,
|
|
37
|
+
paymentIdentifier: selected.paymentIdentifier,
|
|
38
|
+
maxAmount: request.maxAmount,
|
|
39
|
+
privateKey: request.privateKey,
|
|
40
|
+
}).catch((error) => ({
|
|
41
|
+
error: error instanceof Error ? error.message : "Failed to create Sui x402 payment signature.",
|
|
42
|
+
}))
|
|
43
|
+
: await createOfficialX402PaymentSignature(request.paymentRequiredHeader, {
|
|
44
|
+
...request,
|
|
45
|
+
network: selected.network,
|
|
46
|
+
asset: selected.assetAddress ?? selected.asset,
|
|
47
|
+
paymentIdentifier: selected.paymentIdentifier,
|
|
48
|
+
}).catch((error) => ({
|
|
49
|
+
error: error instanceof Error ? error.message : "Failed to create x402 payment signature.",
|
|
50
|
+
}));
|
|
51
|
+
if ("error" in signatureResult) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
message: signatureResult.error,
|
|
55
|
+
paymentRequired,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const paymentSignatureDebug = getPaymentSignatureDebug(signatureResult);
|
|
59
|
+
const paidResponse = await fetchImpl(targetUrl, {
|
|
60
|
+
cache: "no-store",
|
|
61
|
+
headers: {
|
|
62
|
+
[X402_PAYMENT_SIGNATURE_HEADER]: signatureResult.paymentSignature,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const paymentResponseHeader = paidResponse.headers.get(X402_PAYMENT_RESPONSE_HEADER);
|
|
66
|
+
const paymentResponse = paymentResponseHeader ? decodeX402PaymentResponseHeader(paymentResponseHeader) : undefined;
|
|
67
|
+
const body = await readResponseBody(paidResponse);
|
|
68
|
+
if (!paidResponse.ok) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
message: "I generated PAYMENT-SIGNATURE, but the paid retry did not complete successfully.",
|
|
72
|
+
paymentSignatureCreated: true,
|
|
73
|
+
paymentSignatureDebug,
|
|
74
|
+
status: paidResponse.status,
|
|
75
|
+
paymentResponse,
|
|
76
|
+
body,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const txHash = extractSettlementTxHash(paymentResponse);
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
message: txHash
|
|
83
|
+
? `Payment completed. Settlement transaction: ${txHash}`
|
|
84
|
+
: "Payment completed. The backend did not return a settlement transaction hash.",
|
|
85
|
+
paymentSignatureCreated: true,
|
|
86
|
+
paymentSignatureDebug,
|
|
87
|
+
status: paidResponse.status,
|
|
88
|
+
paymentResponse,
|
|
89
|
+
body,
|
|
90
|
+
settlementTxHash: txHash,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function executeOfficialX402AgentPayment(targetUrl, request) {
|
|
94
|
+
const fetchImpl = request.fetchFn ?? fetch;
|
|
95
|
+
const client = await createX402Client(request);
|
|
96
|
+
const fetchWithPayment = wrapFetchWithPayment(fetchImpl, client);
|
|
97
|
+
const response = await fetchWithPayment(targetUrl, { cache: "no-store" });
|
|
98
|
+
const paymentResponseHeader = response.headers.get(X402_PAYMENT_RESPONSE_HEADER);
|
|
99
|
+
const paymentResponse = paymentResponseHeader ? decodeX402PaymentResponseHeader(paymentResponseHeader) : undefined;
|
|
100
|
+
const body = await readResponseBody(response);
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
message: "I attempted the x402 payment, but the paid request did not complete successfully.",
|
|
105
|
+
status: response.status,
|
|
106
|
+
paymentResponse,
|
|
107
|
+
body,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const txHash = extractSettlementTxHash(paymentResponse);
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
message: txHash
|
|
114
|
+
? `Payment completed. Settlement transaction: ${txHash}`
|
|
115
|
+
: "Payment completed. The backend did not return a settlement transaction hash.",
|
|
116
|
+
status: response.status,
|
|
117
|
+
paymentResponse,
|
|
118
|
+
body,
|
|
119
|
+
settlementTxHash: txHash,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function createX402Client(request) {
|
|
123
|
+
assertNetworkMatchesChainType(request.network, request.chainType);
|
|
124
|
+
if (!request.privateKey) {
|
|
125
|
+
throw new Error(`A privateKey is required for ${request.chainType} x402 payments.`);
|
|
126
|
+
}
|
|
127
|
+
const client = new x402Client();
|
|
128
|
+
if (request.chainType === "EVM") {
|
|
129
|
+
const evmSigner = privateKeyToAccount(asHex(request.privateKey));
|
|
130
|
+
client.register("eip155:*", new ExactEvmScheme(evmSigner));
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
if (request.chainType === "SOL") {
|
|
134
|
+
const svmSigner = await createSolanaSigner(request.privateKey);
|
|
135
|
+
client.register("solana:*", new ExactSvmScheme(svmSigner));
|
|
136
|
+
return client;
|
|
137
|
+
}
|
|
138
|
+
if (request.chainType === "SUI") {
|
|
139
|
+
client.register("sui:*", new ExactSuiScheme({ privateKey: request.privateKey }));
|
|
140
|
+
return client;
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`Unsupported x402 chainType: ${request.chainType}`);
|
|
143
|
+
}
|
|
144
|
+
async function createOfficialX402PaymentSignature(paymentRequiredHeader, requested) {
|
|
145
|
+
const paymentRequired = decodeX402PaymentRequiredHeader(paymentRequiredHeader);
|
|
146
|
+
const selected = selectX402AgentPaymentAccept(paymentRequired.accepts, requested);
|
|
147
|
+
if (!selected) {
|
|
148
|
+
throw new Error("No compatible x402 payment option found for the requested chain or token.");
|
|
149
|
+
}
|
|
150
|
+
selectPaymentAccept(paymentRequired, {
|
|
151
|
+
paymentIdentifier: selected.paymentIdentifier,
|
|
152
|
+
network: selected.network,
|
|
153
|
+
asset: selected.assetAddress ?? selected.asset,
|
|
154
|
+
maxAmount: requested.maxAmount,
|
|
155
|
+
});
|
|
156
|
+
if (isUnsupportedNativeExactAccept(selected)) {
|
|
157
|
+
throw new Error("Native ETH and native SOL x402 accepts are not currently supported by PayKit headless. Use a token accept such as USDC, or have the server return a prepared native transaction/signature format.");
|
|
158
|
+
}
|
|
159
|
+
const client = await createX402Client({
|
|
160
|
+
...requested,
|
|
161
|
+
network: selected.network,
|
|
162
|
+
});
|
|
163
|
+
const paymentPayload = await client.createPaymentPayload({
|
|
164
|
+
...paymentRequired,
|
|
165
|
+
accepts: [selected],
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
paymentSignature: encodePaymentSignatureHeader(paymentPayload),
|
|
169
|
+
debug: createPaymentSignatureDebug(paymentPayload),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function getPaymentSignatureDebug(signatureResult) {
|
|
173
|
+
return signatureResult.debug;
|
|
174
|
+
}
|
|
175
|
+
function createPaymentSignatureDebug(paymentPayload) {
|
|
176
|
+
if (!isRecord(paymentPayload))
|
|
177
|
+
return undefined;
|
|
178
|
+
const accepted = isRecord(paymentPayload.accepted) ? paymentPayload.accepted : undefined;
|
|
179
|
+
const payload = isRecord(paymentPayload.payload) ? paymentPayload.payload : undefined;
|
|
180
|
+
const debug = {
|
|
181
|
+
...(accepted
|
|
182
|
+
? {
|
|
183
|
+
accepted: {
|
|
184
|
+
network: typeof accepted.network === "string" ? accepted.network : undefined,
|
|
185
|
+
asset: typeof accepted.asset === "string" ? accepted.asset : undefined,
|
|
186
|
+
amount: typeof accepted.amount === "string" ? accepted.amount : undefined,
|
|
187
|
+
payTo: typeof accepted.payTo === "string" ? accepted.payTo : undefined,
|
|
188
|
+
paymentIdentifier: typeof accepted.paymentIdentifier === "string" ? accepted.paymentIdentifier : undefined,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
192
|
+
};
|
|
193
|
+
if (payload && typeof payload.transaction === "string") {
|
|
194
|
+
if (typeof accepted?.network === "string" && isSuiNetwork(accepted.network)) {
|
|
195
|
+
const suiDebug = createSuiPaymentSignatureDebug(paymentPayload);
|
|
196
|
+
debug.sui = suiDebug.sui;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
try {
|
|
200
|
+
const transaction = decodeTransactionFromPayload({ transaction: payload.transaction });
|
|
201
|
+
const signatureEntries = Object.entries(transaction.signatures ?? {});
|
|
202
|
+
const extra = isRecord(accepted?.extra) ? accepted.extra : undefined;
|
|
203
|
+
debug.svm = {
|
|
204
|
+
feePayer: typeof extra?.feePayer === "string" ? extra.feePayer : undefined,
|
|
205
|
+
tokenPayer: getTokenPayerFromTransaction(transaction) || undefined,
|
|
206
|
+
signedAddresses: signatureEntries.filter(([, signature]) => Boolean(signature)).map(([address]) => address),
|
|
207
|
+
missingSignatureAddresses: signatureEntries.filter(([, signature]) => !signature).map(([address]) => address),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return debug;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return debug.accepted || debug.svm || debug.sui ? debug : undefined;
|
|
216
|
+
}
|
|
217
|
+
async function executeSuiX402AgentPayment(targetUrl, request) {
|
|
218
|
+
const fetchImpl = request.fetchFn ?? fetch;
|
|
219
|
+
const initialResponse = await fetchImpl(targetUrl, { cache: "no-store" });
|
|
220
|
+
const paymentRequiredHeader = initialResponse.headers.get(X402_PAYMENT_REQUIRED_HEADER);
|
|
221
|
+
if (initialResponse.status !== 402 || !paymentRequiredHeader) {
|
|
222
|
+
const initialResponseBody = await readResponseBody(initialResponse);
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
message: "I could not get a PAYMENT-REQUIRED challenge from the x402 resource.",
|
|
226
|
+
status: initialResponse.status,
|
|
227
|
+
body: initialResponseBody,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const paymentRequired = decodeX402PaymentRequiredHeader(paymentRequiredHeader);
|
|
231
|
+
const paymentSignatureResult = await createSuiX402PaymentSignatureHeader(paymentRequiredHeader, {
|
|
232
|
+
network: request.network,
|
|
233
|
+
asset: request.asset,
|
|
234
|
+
paymentIdentifier: request.paymentIdentifier,
|
|
235
|
+
maxAmount: request.maxAmount,
|
|
236
|
+
privateKey: request.privateKey,
|
|
237
|
+
}).catch((error) => ({
|
|
238
|
+
error: error instanceof Error ? error.message : "Failed to create Sui x402 payment signature.",
|
|
239
|
+
}));
|
|
240
|
+
if ("error" in paymentSignatureResult) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
message: paymentSignatureResult.error,
|
|
244
|
+
paymentRequired,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const paymentSignatureDebug = getPaymentSignatureDebug(paymentSignatureResult);
|
|
248
|
+
const paidResponse = await fetchImpl(targetUrl, {
|
|
249
|
+
cache: "no-store",
|
|
250
|
+
headers: {
|
|
251
|
+
[X402_PAYMENT_SIGNATURE_HEADER]: paymentSignatureResult.paymentSignature,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
const paymentResponseHeader = paidResponse.headers.get(X402_PAYMENT_RESPONSE_HEADER);
|
|
255
|
+
const paymentResponse = paymentResponseHeader ? decodeX402PaymentResponseHeader(paymentResponseHeader) : undefined;
|
|
256
|
+
const body = await readResponseBody(paidResponse);
|
|
257
|
+
if (!paidResponse.ok) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
message: "I attempted the payment, but the paid retry did not complete successfully.",
|
|
261
|
+
paymentSignatureCreated: true,
|
|
262
|
+
paymentSignatureDebug,
|
|
263
|
+
status: paidResponse.status,
|
|
264
|
+
paymentResponse,
|
|
265
|
+
body,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const txHash = extractSettlementTxHash(paymentResponse);
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
message: txHash
|
|
272
|
+
? `Payment completed. Settlement transaction: ${txHash}`
|
|
273
|
+
: "Payment completed. The backend did not return a settlement transaction hash.",
|
|
274
|
+
status: paidResponse.status,
|
|
275
|
+
paymentResponse,
|
|
276
|
+
body,
|
|
277
|
+
paymentSignatureCreated: true,
|
|
278
|
+
paymentSignatureDebug,
|
|
279
|
+
settlementTxHash: txHash,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export function selectX402AgentPaymentAccept(accepts, requested) {
|
|
283
|
+
const normalizedNetwork = requested.network?.toLowerCase();
|
|
284
|
+
return accepts.find((accept) => {
|
|
285
|
+
if (requested.chainType && !networkMatchesChainType(accept.network, requested.chainType))
|
|
286
|
+
return false;
|
|
287
|
+
if (requested.paymentIdentifier && accept.paymentIdentifier !== requested.paymentIdentifier)
|
|
288
|
+
return false;
|
|
289
|
+
if (requested.asset && !assetsMatch(accept.network, accept.assetAddress ?? accept.asset, requested.asset))
|
|
290
|
+
return false;
|
|
291
|
+
if (!normalizedNetwork || normalizedNetwork === "auto")
|
|
292
|
+
return true;
|
|
293
|
+
if (normalizedNetwork === "evm")
|
|
294
|
+
return accept.network.startsWith("eip155:");
|
|
295
|
+
if (normalizedNetwork === "solana")
|
|
296
|
+
return accept.network.startsWith("solana:");
|
|
297
|
+
if (normalizedNetwork === "sui")
|
|
298
|
+
return accept.network.startsWith("sui:");
|
|
299
|
+
return accept.network.toLowerCase() === normalizedNetwork;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function assertNetworkMatchesChainType(network, chainType) {
|
|
303
|
+
if (!networkMatchesChainType(network, chainType)) {
|
|
304
|
+
throw new Error(`network ${network} does not match chainType ${chainType}.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function networkMatchesChainType(network, chainType) {
|
|
308
|
+
const normalizedNetwork = network?.toLowerCase();
|
|
309
|
+
if (!normalizedNetwork || normalizedNetwork === "auto")
|
|
310
|
+
return true;
|
|
311
|
+
if (chainType === "EVM") {
|
|
312
|
+
return normalizedNetwork === "evm" || normalizedNetwork.startsWith("eip155:");
|
|
313
|
+
}
|
|
314
|
+
if (chainType === "SOL") {
|
|
315
|
+
return normalizedNetwork === "sol" || normalizedNetwork === "solana" || normalizedNetwork.startsWith("solana:");
|
|
316
|
+
}
|
|
317
|
+
if (chainType === "SUI") {
|
|
318
|
+
return normalizedNetwork === "sui" || normalizedNetwork.startsWith("sui:");
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
function isUnsupportedNativeExactAccept(accept) {
|
|
323
|
+
const network = accept.network.toLowerCase();
|
|
324
|
+
const asset = (accept.assetAddress ?? accept.asset)?.trim().toLowerCase();
|
|
325
|
+
const isNativeEth = network.startsWith("eip155:") &&
|
|
326
|
+
(!asset ||
|
|
327
|
+
asset === "native" ||
|
|
328
|
+
asset === "eth" ||
|
|
329
|
+
asset === "native-eth" ||
|
|
330
|
+
asset === "native_eth" ||
|
|
331
|
+
asset === "native:eth" ||
|
|
332
|
+
asset === "0x0000000000000000000000000000000000000000" ||
|
|
333
|
+
asset === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
|
|
334
|
+
const isNativeSol = network.startsWith("solana:") &&
|
|
335
|
+
(!asset ||
|
|
336
|
+
asset === "native" ||
|
|
337
|
+
asset === "sol" ||
|
|
338
|
+
asset === "native-sol" ||
|
|
339
|
+
asset === "native_sol" ||
|
|
340
|
+
asset === "native:sol" ||
|
|
341
|
+
asset === "11111111111111111111111111111111");
|
|
342
|
+
return isNativeEth || isNativeSol;
|
|
343
|
+
}
|
|
344
|
+
async function createSolanaSigner(privateKey) {
|
|
345
|
+
const bytes = decodePrivateKeyBytes(privateKey.trim());
|
|
346
|
+
if (bytes.length === 64) {
|
|
347
|
+
return createKeyPairSignerFromBytes(bytes);
|
|
348
|
+
}
|
|
349
|
+
if (bytes.length === 32) {
|
|
350
|
+
return createKeyPairSignerFromPrivateKeyBytes(bytes);
|
|
351
|
+
}
|
|
352
|
+
throw new Error("Solana private key must be a 32-byte seed or 64-byte secret key.");
|
|
353
|
+
}
|
|
354
|
+
function decodePrivateKeyBytes(privateKey) {
|
|
355
|
+
const hexValue = privateKey.startsWith("0x") ? privateKey.slice(2) : privateKey;
|
|
356
|
+
if (hexValue.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(hexValue)) {
|
|
357
|
+
return hex.decode(hexValue);
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
return base58.decode(privateKey);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return base64.decode(privateKey);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function extractSettlementTxHash(paymentResponse) {
|
|
367
|
+
if (typeof paymentResponse !== "object" || paymentResponse === null)
|
|
368
|
+
return undefined;
|
|
369
|
+
const value = paymentResponse.settlementTxHash;
|
|
370
|
+
return typeof value === "string" ? value : undefined;
|
|
371
|
+
}
|
|
372
|
+
function isRecord(value) {
|
|
373
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
374
|
+
}
|
|
375
|
+
function assetsMatch(network, left, right) {
|
|
376
|
+
return normalizeAssetForNetwork(left, network) === normalizeAssetForNetwork(right, network);
|
|
377
|
+
}
|
|
378
|
+
function normalizeAssetForNetwork(asset, network) {
|
|
379
|
+
return network.toLowerCase().startsWith("eip155:") && asset.startsWith("0x") ? asset.toLowerCase() : asset;
|
|
380
|
+
}
|
|
381
|
+
function asHex(value) {
|
|
382
|
+
return value.startsWith("0x") ? value : `0x${value}`;
|
|
383
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const X402_PAYMENT_REQUIRED_HEADER = "PAYMENT-REQUIRED";
|
|
2
|
+
export declare const X402_PAYMENT_SIGNATURE_HEADER = "PAYMENT-SIGNATURE";
|
|
3
|
+
export declare const X402_PAYMENT_RESPONSE_HEADER = "PAYMENT-RESPONSE";
|
|
4
|
+
export type X402Network = `eip155:${number}` | `solana:${string}` | `sui:${string}` | string;
|
|
5
|
+
export type X402PaymentAccept = {
|
|
6
|
+
scheme: "exact" | string;
|
|
7
|
+
network: X402Network;
|
|
8
|
+
asset: `0x${string}` | string;
|
|
9
|
+
assetAddress?: `0x${string}` | string;
|
|
10
|
+
amount: string;
|
|
11
|
+
payTo: `0x${string}` | string;
|
|
12
|
+
paymentIdentifier: string;
|
|
13
|
+
maxTimeoutSeconds?: number;
|
|
14
|
+
extra?: {
|
|
15
|
+
name?: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
assetTransferMethod?: "eip3009" | "permit2" | string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
expiresAt?: string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
export type X402PaymentRequired = {
|
|
24
|
+
x402Version?: number;
|
|
25
|
+
accepts: X402PaymentAccept[];
|
|
26
|
+
metadata?: X402Metadata;
|
|
27
|
+
resource?: Record<string, unknown>;
|
|
28
|
+
extensions?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
export type X402Metadata = {
|
|
31
|
+
payOrderId: string;
|
|
32
|
+
organizationId: string;
|
|
33
|
+
purpose?: string;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
export type X402PreferredChainType = "EVM" | "SOL" | "SUI";
|
|
37
|
+
export type X402RequirementRequest = {
|
|
38
|
+
preferred_chain_type: X402PreferredChainType;
|
|
39
|
+
preferred_chain_id?: number;
|
|
40
|
+
preferred_token_address: `0x${string}` | string;
|
|
41
|
+
};
|
|
42
|
+
export type X402PreparedPayment = {
|
|
43
|
+
x402Version?: number;
|
|
44
|
+
scheme: "exact" | string;
|
|
45
|
+
network: X402Network;
|
|
46
|
+
accepted?: X402PaymentAccept;
|
|
47
|
+
paymentIdentifier: string;
|
|
48
|
+
payload: Record<string, unknown>;
|
|
49
|
+
signature?: string;
|
|
50
|
+
};
|
|
51
|
+
export type X402AcceptSelectionOptions = {
|
|
52
|
+
network?: string;
|
|
53
|
+
asset?: string;
|
|
54
|
+
paymentIdentifier?: string;
|
|
55
|
+
maxAmount?: string | bigint;
|
|
56
|
+
now?: Date;
|
|
57
|
+
};
|
|
58
|
+
export declare function decodeX402PaymentRequiredHeader(header: string): X402PaymentRequired;
|
|
59
|
+
export declare function decodeX402PaymentResponseHeader(header: string): unknown;
|
|
60
|
+
export declare function createPreparedX402PaymentSignatureHeader(paymentRequiredHeader: string, payment: X402PreparedPayment, options?: X402AcceptSelectionOptions): string;
|
|
61
|
+
export declare function selectPaymentAccept(paymentRequired: X402PaymentRequired, options: X402AcceptSelectionOptions): X402PaymentAccept;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { base64 } from "@scure/base";
|
|
2
|
+
export const X402_PAYMENT_REQUIRED_HEADER = "PAYMENT-REQUIRED";
|
|
3
|
+
export const X402_PAYMENT_SIGNATURE_HEADER = "PAYMENT-SIGNATURE";
|
|
4
|
+
export const X402_PAYMENT_RESPONSE_HEADER = "PAYMENT-RESPONSE";
|
|
5
|
+
export function decodeX402PaymentRequiredHeader(header) {
|
|
6
|
+
const paymentRequired = decodeBase64Json(header);
|
|
7
|
+
if (!Array.isArray(paymentRequired.accepts) || paymentRequired.accepts.length === 0) {
|
|
8
|
+
throw new Error("Invalid PAYMENT-REQUIRED header: accepts must contain at least one payment option");
|
|
9
|
+
}
|
|
10
|
+
return paymentRequired;
|
|
11
|
+
}
|
|
12
|
+
export function decodeX402PaymentResponseHeader(header) {
|
|
13
|
+
return decodeBase64Json(header);
|
|
14
|
+
}
|
|
15
|
+
export function createPreparedX402PaymentSignatureHeader(paymentRequiredHeader, payment, options = {}) {
|
|
16
|
+
const paymentRequired = decodeX402PaymentRequiredHeader(paymentRequiredHeader);
|
|
17
|
+
selectPaymentAccept(paymentRequired, {
|
|
18
|
+
...options,
|
|
19
|
+
network: payment.network,
|
|
20
|
+
asset: payment.accepted?.asset ?? payment.accepted?.assetAddress ?? options.asset,
|
|
21
|
+
paymentIdentifier: payment.paymentIdentifier,
|
|
22
|
+
});
|
|
23
|
+
return encodeBase64Json(payment);
|
|
24
|
+
}
|
|
25
|
+
export function selectPaymentAccept(paymentRequired, options) {
|
|
26
|
+
const accepted = paymentRequired.accepts.find((accept) => {
|
|
27
|
+
if (accept.scheme !== "exact")
|
|
28
|
+
return false;
|
|
29
|
+
if (options.network && accept.network !== options.network)
|
|
30
|
+
return false;
|
|
31
|
+
if (options.asset && !assetsMatch(accept.network, accept.assetAddress ?? accept.asset, options.asset))
|
|
32
|
+
return false;
|
|
33
|
+
if (options.paymentIdentifier && accept.paymentIdentifier !== options.paymentIdentifier)
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
if (!accepted) {
|
|
38
|
+
throw new Error("No compatible x402 payment option found");
|
|
39
|
+
}
|
|
40
|
+
if (options.maxAmount !== undefined && BigInt(accepted.amount) > BigInt(options.maxAmount)) {
|
|
41
|
+
throw new Error("x402 payment amount exceeds maxAmount");
|
|
42
|
+
}
|
|
43
|
+
if (accepted.expiresAt) {
|
|
44
|
+
const expiresAtMs = parseX402DateTime(accepted.expiresAt);
|
|
45
|
+
if (expiresAtMs <= (options.now ?? new Date()).getTime()) {
|
|
46
|
+
throw new Error("x402 payment requirement has expired");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return accepted;
|
|
50
|
+
}
|
|
51
|
+
function parseX402DateTime(value) {
|
|
52
|
+
const timestamp = new Date(value).getTime();
|
|
53
|
+
if (Number.isNaN(timestamp)) {
|
|
54
|
+
throw new Error(`Invalid x402 payment requirement expiration: ${value}`);
|
|
55
|
+
}
|
|
56
|
+
return timestamp;
|
|
57
|
+
}
|
|
58
|
+
function assetsMatch(network, left, right) {
|
|
59
|
+
return normalizeAssetForNetwork(left, network) === normalizeAssetForNetwork(right, network);
|
|
60
|
+
}
|
|
61
|
+
function normalizeAssetForNetwork(asset, network) {
|
|
62
|
+
return network.toLowerCase().startsWith("eip155:") && asset.startsWith("0x") ? asset.toLowerCase() : asset;
|
|
63
|
+
}
|
|
64
|
+
function encodeBase64Json(value) {
|
|
65
|
+
return base64.encode(new TextEncoder().encode(JSON.stringify(value)));
|
|
66
|
+
}
|
|
67
|
+
function decodeBase64Json(value) {
|
|
68
|
+
return JSON.parse(new TextDecoder().decode(base64.decode(value)));
|
|
69
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { x402Client } from "@x402/core/client";
|
|
2
|
+
import type { Network, PaymentPayload, PaymentPayloadResult, PaymentRequirements, SchemeNetworkClient } from "@x402/core/types";
|
|
3
|
+
declare const COINVOYAGE_SUI_X402_PAYLOAD_VERSION = "coinvoyage-sui-ptb-v1";
|
|
4
|
+
export type SuiX402SignerOptions = {
|
|
5
|
+
privateKey?: string;
|
|
6
|
+
rpcUrl?: string;
|
|
7
|
+
};
|
|
8
|
+
export type SuiX402PaymentSignatureOptions = SuiX402SignerOptions & {
|
|
9
|
+
network?: string;
|
|
10
|
+
asset?: string;
|
|
11
|
+
paymentIdentifier?: string;
|
|
12
|
+
maxAmount?: string | bigint;
|
|
13
|
+
};
|
|
14
|
+
export type SuiX402PaymentPayload = {
|
|
15
|
+
version: typeof COINVOYAGE_SUI_X402_PAYLOAD_VERSION;
|
|
16
|
+
transaction: string;
|
|
17
|
+
txBytes: string;
|
|
18
|
+
transactionBytes: string;
|
|
19
|
+
signature: string;
|
|
20
|
+
signatures: string[];
|
|
21
|
+
payer: string;
|
|
22
|
+
amount: string;
|
|
23
|
+
payTo: string;
|
|
24
|
+
asset: string;
|
|
25
|
+
assetAddress?: string;
|
|
26
|
+
coinType: string;
|
|
27
|
+
paymentIdentifier?: string;
|
|
28
|
+
};
|
|
29
|
+
export type SuiX402PaymentSignatureDebug = {
|
|
30
|
+
accepted: {
|
|
31
|
+
network: string;
|
|
32
|
+
asset: string;
|
|
33
|
+
amount: string;
|
|
34
|
+
payTo: string;
|
|
35
|
+
paymentIdentifier?: string;
|
|
36
|
+
};
|
|
37
|
+
sui: {
|
|
38
|
+
payer: string;
|
|
39
|
+
coinType: string;
|
|
40
|
+
transactionBytes: string;
|
|
41
|
+
signatures: string[];
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export declare class ExactSuiScheme implements SchemeNetworkClient {
|
|
45
|
+
private readonly options;
|
|
46
|
+
readonly scheme = "exact";
|
|
47
|
+
constructor(options: SuiX402SignerOptions);
|
|
48
|
+
createPaymentPayload(x402Version: number, paymentRequirements: PaymentRequirements): Promise<PaymentPayloadResult>;
|
|
49
|
+
}
|
|
50
|
+
export declare function registerExactSuiScheme(client: x402Client, options: SuiX402SignerOptions & {
|
|
51
|
+
networks?: Network[];
|
|
52
|
+
}): x402Client;
|
|
53
|
+
export declare function createSuiX402PaymentSignatureHeader(paymentRequiredHeader: string, options: SuiX402PaymentSignatureOptions): Promise<{
|
|
54
|
+
paymentSignature: string;
|
|
55
|
+
paymentPayload: PaymentPayload;
|
|
56
|
+
debug: SuiX402PaymentSignatureDebug;
|
|
57
|
+
}>;
|
|
58
|
+
export declare function createSuiPaymentSignatureDebug(paymentPayload: PaymentPayload): SuiX402PaymentSignatureDebug;
|
|
59
|
+
export declare function isSuiNetwork(network?: string): boolean;
|
|
60
|
+
export {};
|
package/dist/x402/sui.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { decodeX402PaymentRequiredHeader, selectPaymentAccept, } from "./core";
|
|
2
|
+
import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
|
|
3
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
4
|
+
import { Transaction as SuiTransaction } from "@mysten/sui/transactions";
|
|
5
|
+
import { base58, base64, hex } from "@scure/base";
|
|
6
|
+
import { encodePaymentSignatureHeader } from "@x402/core/http";
|
|
7
|
+
const SUI_NATIVE_COIN_TYPE = "0x2::sui::SUI";
|
|
8
|
+
const SUI_MAINNET_RPC_URL = "https://fullnode.mainnet.sui.io:443";
|
|
9
|
+
const COINVOYAGE_SUI_X402_PAYLOAD_VERSION = "coinvoyage-sui-ptb-v1";
|
|
10
|
+
export class ExactSuiScheme {
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.scheme = "exact";
|
|
14
|
+
}
|
|
15
|
+
async createPaymentPayload(x402Version, paymentRequirements) {
|
|
16
|
+
if (!this.options.privateKey) {
|
|
17
|
+
throw new Error("A Sui private key is required for Sui x402 payments.");
|
|
18
|
+
}
|
|
19
|
+
const accept = paymentRequirements;
|
|
20
|
+
const txBytes = getPreparedSuiTransactionBytes(accept);
|
|
21
|
+
const signed = txBytes
|
|
22
|
+
? await signSuiTransaction(txBytes, this.options.privateKey, getSuiCoinType(accept))
|
|
23
|
+
: await createAndSignSuiTransferTransaction(accept, {
|
|
24
|
+
...this.options,
|
|
25
|
+
privateKey: this.options.privateKey,
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
x402Version,
|
|
29
|
+
payload: createSuiSignedTransactionPayload(accept, signed),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function registerExactSuiScheme(client, options) {
|
|
34
|
+
const networks = options.networks ?? ["sui:*"];
|
|
35
|
+
const scheme = new ExactSuiScheme(options);
|
|
36
|
+
for (const network of networks) {
|
|
37
|
+
client.register(network, scheme);
|
|
38
|
+
}
|
|
39
|
+
return client;
|
|
40
|
+
}
|
|
41
|
+
export async function createSuiX402PaymentSignatureHeader(paymentRequiredHeader, options) {
|
|
42
|
+
if (!options.privateKey) {
|
|
43
|
+
throw new Error("A Sui private key is required for Sui x402 payments.");
|
|
44
|
+
}
|
|
45
|
+
const paymentRequired = decodeX402PaymentRequiredHeader(paymentRequiredHeader);
|
|
46
|
+
const selected = selectSuiX402PaymentAccept(paymentRequired.accepts, options);
|
|
47
|
+
if (!selected) {
|
|
48
|
+
throw new Error("No compatible Sui x402 payment option found for the requested chain or token.");
|
|
49
|
+
}
|
|
50
|
+
selectPaymentAccept(paymentRequired, {
|
|
51
|
+
paymentIdentifier: selected.paymentIdentifier,
|
|
52
|
+
network: selected.network,
|
|
53
|
+
asset: selected.assetAddress ?? selected.asset,
|
|
54
|
+
maxAmount: options.maxAmount,
|
|
55
|
+
});
|
|
56
|
+
const scheme = new ExactSuiScheme(options);
|
|
57
|
+
const partialPayload = await scheme.createPaymentPayload(paymentRequired.x402Version ?? 2, selected);
|
|
58
|
+
const paymentPayload = createSuiPaymentPayload(paymentRequired, selected, partialPayload);
|
|
59
|
+
return {
|
|
60
|
+
paymentSignature: encodePaymentSignatureHeader(paymentPayload),
|
|
61
|
+
paymentPayload,
|
|
62
|
+
debug: createSuiPaymentSignatureDebug(paymentPayload),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function createSuiPaymentSignatureDebug(paymentPayload) {
|
|
66
|
+
const accepted = paymentPayload.accepted;
|
|
67
|
+
const payload = paymentPayload.payload;
|
|
68
|
+
return {
|
|
69
|
+
accepted: {
|
|
70
|
+
network: accepted.network,
|
|
71
|
+
asset: accepted.assetAddress ?? accepted.asset,
|
|
72
|
+
amount: accepted.amount,
|
|
73
|
+
payTo: accepted.payTo,
|
|
74
|
+
paymentIdentifier: accepted.paymentIdentifier,
|
|
75
|
+
},
|
|
76
|
+
sui: {
|
|
77
|
+
payer: payload.payer,
|
|
78
|
+
coinType: payload.coinType,
|
|
79
|
+
transactionBytes: payload.transaction,
|
|
80
|
+
signatures: payload.signatures,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function isSuiNetwork(network) {
|
|
85
|
+
const normalizedNetwork = network?.toLowerCase();
|
|
86
|
+
return normalizedNetwork === "sui" || normalizedNetwork?.startsWith("sui:") === true;
|
|
87
|
+
}
|
|
88
|
+
function createSuiPaymentPayload(paymentRequired, selected, partialPayload) {
|
|
89
|
+
return {
|
|
90
|
+
x402Version: partialPayload.x402Version,
|
|
91
|
+
resource: paymentRequired.resource,
|
|
92
|
+
accepted: selected,
|
|
93
|
+
payload: partialPayload.payload,
|
|
94
|
+
extensions: mergeExtensions(paymentRequired.extensions, partialPayload.extensions),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function mergeExtensions(serverExtensions, schemeExtensions) {
|
|
98
|
+
if (!schemeExtensions)
|
|
99
|
+
return serverExtensions;
|
|
100
|
+
if (!serverExtensions)
|
|
101
|
+
return schemeExtensions;
|
|
102
|
+
const merged = { ...serverExtensions };
|
|
103
|
+
for (const [key, value] of Object.entries(schemeExtensions)) {
|
|
104
|
+
const serverValue = merged[key];
|
|
105
|
+
merged[key] =
|
|
106
|
+
isRecord(serverValue) && isRecord(value)
|
|
107
|
+
? {
|
|
108
|
+
...serverValue,
|
|
109
|
+
...value,
|
|
110
|
+
}
|
|
111
|
+
: value;
|
|
112
|
+
}
|
|
113
|
+
return merged;
|
|
114
|
+
}
|
|
115
|
+
function selectSuiX402PaymentAccept(accepts, requested) {
|
|
116
|
+
const normalizedNetwork = requested.network?.toLowerCase();
|
|
117
|
+
return accepts.find((accept) => {
|
|
118
|
+
if (!isSuiNetwork(accept.network))
|
|
119
|
+
return false;
|
|
120
|
+
if (requested.paymentIdentifier && accept.paymentIdentifier !== requested.paymentIdentifier)
|
|
121
|
+
return false;
|
|
122
|
+
if (requested.asset && !assetsMatch(accept.network, accept.assetAddress ?? accept.asset, requested.asset)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (!normalizedNetwork || normalizedNetwork === "auto" || normalizedNetwork === "sui")
|
|
126
|
+
return true;
|
|
127
|
+
return accept.network.toLowerCase() === normalizedNetwork;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function createSuiSignedTransactionPayload(accept, signed) {
|
|
131
|
+
const asset = accept.assetAddress ?? accept.asset;
|
|
132
|
+
return {
|
|
133
|
+
version: COINVOYAGE_SUI_X402_PAYLOAD_VERSION,
|
|
134
|
+
transaction: signed.txBytes,
|
|
135
|
+
txBytes: signed.txBytes,
|
|
136
|
+
transactionBytes: signed.txBytes,
|
|
137
|
+
signature: signed.signature,
|
|
138
|
+
signatures: [signed.signature],
|
|
139
|
+
payer: signed.payer,
|
|
140
|
+
amount: accept.amount,
|
|
141
|
+
payTo: accept.payTo,
|
|
142
|
+
asset,
|
|
143
|
+
...(accept.assetAddress ? { assetAddress: accept.assetAddress } : {}),
|
|
144
|
+
coinType: signed.coinType,
|
|
145
|
+
...(accept.paymentIdentifier ? { paymentIdentifier: accept.paymentIdentifier } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function getPreparedSuiTransactionBytes(accept) {
|
|
149
|
+
const extraSui = isRecord(accept.extra?.sui) ? accept.extra.sui : undefined;
|
|
150
|
+
const value = accept.transactionBytes ?? accept.txBytes ?? extraSui?.transactionBytes ?? extraSui?.txBytes;
|
|
151
|
+
return value === undefined ? undefined : toBytes(value);
|
|
152
|
+
}
|
|
153
|
+
function toBytes(value) {
|
|
154
|
+
if (value instanceof Uint8Array)
|
|
155
|
+
return value;
|
|
156
|
+
if (Array.isArray(value)) {
|
|
157
|
+
if (!value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
|
158
|
+
throw new Error("Transaction byte array must contain byte values");
|
|
159
|
+
}
|
|
160
|
+
return Uint8Array.from(value);
|
|
161
|
+
}
|
|
162
|
+
if (typeof value !== "string") {
|
|
163
|
+
throw new Error("Transaction bytes must be a string or byte array");
|
|
164
|
+
}
|
|
165
|
+
const normalized = value.trim();
|
|
166
|
+
const hexValue = normalized.startsWith("0x") ? normalized.slice(2) : normalized;
|
|
167
|
+
if (hexValue.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(hexValue)) {
|
|
168
|
+
return hex.decode(hexValue);
|
|
169
|
+
}
|
|
170
|
+
return base64.decode(normalized);
|
|
171
|
+
}
|
|
172
|
+
async function createAndSignSuiTransferTransaction(accept, options) {
|
|
173
|
+
const keypair = createSuiKeypair(options.privateKey);
|
|
174
|
+
const payer = keypair.toSuiAddress();
|
|
175
|
+
const client = createSuiClient(options.rpcUrl);
|
|
176
|
+
const tx = new SuiTransaction();
|
|
177
|
+
const amount = BigInt(accept.amount);
|
|
178
|
+
const coinType = getSuiCoinType(accept);
|
|
179
|
+
if (!accept.payTo) {
|
|
180
|
+
throw new Error("Selected Sui x402 payment option did not include payTo.");
|
|
181
|
+
}
|
|
182
|
+
if (amount <= 0n) {
|
|
183
|
+
throw new Error("Selected Sui x402 payment amount must be greater than zero.");
|
|
184
|
+
}
|
|
185
|
+
tx.setSender(payer);
|
|
186
|
+
if (isSuiNativeCoin(coinType)) {
|
|
187
|
+
const [paymentCoin] = tx.splitCoins(tx.gas, [amount]);
|
|
188
|
+
tx.transferObjects([paymentCoin], accept.payTo);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const paymentCoin = await createExactSuiTokenCoin(tx, client, {
|
|
192
|
+
owner: payer,
|
|
193
|
+
coinType,
|
|
194
|
+
amount,
|
|
195
|
+
});
|
|
196
|
+
tx.transferObjects([paymentCoin], accept.payTo);
|
|
197
|
+
}
|
|
198
|
+
const gasPrice = await client.core.getReferenceGasPrice();
|
|
199
|
+
tx.setGasPrice(gasPrice.referenceGasPrice);
|
|
200
|
+
const signed = await tx.sign({ client, signer: keypair });
|
|
201
|
+
return {
|
|
202
|
+
txBytes: signed.bytes,
|
|
203
|
+
signature: signed.signature,
|
|
204
|
+
payer,
|
|
205
|
+
coinType,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async function createExactSuiTokenCoin(tx, client, options) {
|
|
209
|
+
const coins = await getSuiCoinsForAmount(client, options);
|
|
210
|
+
const primaryCoin = tx.object(coins[0].objectId);
|
|
211
|
+
const remainingCoins = coins.slice(1).map((coin) => tx.object(coin.objectId));
|
|
212
|
+
const total = coins.reduce((sum, coin) => sum + BigInt(coin.balance), 0n);
|
|
213
|
+
if (remainingCoins.length > 0) {
|
|
214
|
+
tx.mergeCoins(primaryCoin, remainingCoins);
|
|
215
|
+
}
|
|
216
|
+
if (total === options.amount) {
|
|
217
|
+
return primaryCoin;
|
|
218
|
+
}
|
|
219
|
+
const [paymentCoin] = tx.splitCoins(primaryCoin, [options.amount]);
|
|
220
|
+
return paymentCoin;
|
|
221
|
+
}
|
|
222
|
+
async function getSuiCoinsForAmount(client, options) {
|
|
223
|
+
const coins = [];
|
|
224
|
+
let total = 0n;
|
|
225
|
+
let cursor = null;
|
|
226
|
+
do {
|
|
227
|
+
const page = await client.core.listCoins({
|
|
228
|
+
owner: options.owner,
|
|
229
|
+
coinType: options.coinType,
|
|
230
|
+
cursor,
|
|
231
|
+
});
|
|
232
|
+
for (const coin of page.objects) {
|
|
233
|
+
coins.push({
|
|
234
|
+
objectId: coin.objectId,
|
|
235
|
+
balance: coin.balance,
|
|
236
|
+
});
|
|
237
|
+
total += BigInt(coin.balance);
|
|
238
|
+
if (total >= options.amount)
|
|
239
|
+
return coins;
|
|
240
|
+
}
|
|
241
|
+
cursor = page.hasNextPage ? page.cursor : null;
|
|
242
|
+
} while (cursor);
|
|
243
|
+
throw new Error(`Insufficient Sui coin balance for ${options.coinType}: need ${options.amount.toString()}, found ${total.toString()}.`);
|
|
244
|
+
}
|
|
245
|
+
function createSuiClient(rpcUrl) {
|
|
246
|
+
return new SuiJsonRpcClient({
|
|
247
|
+
network: "mainnet",
|
|
248
|
+
url: rpcUrl ?? SUI_MAINNET_RPC_URL,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
async function signSuiTransaction(transactionBytes, privateKey, coinType) {
|
|
252
|
+
const keypair = createSuiKeypair(privateKey);
|
|
253
|
+
const signed = await keypair.signTransaction(transactionBytes);
|
|
254
|
+
return {
|
|
255
|
+
txBytes: signed.bytes,
|
|
256
|
+
signature: signed.signature,
|
|
257
|
+
payer: keypair.toSuiAddress(),
|
|
258
|
+
coinType,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function createSuiKeypair(privateKey) {
|
|
262
|
+
const normalized = privateKey.trim();
|
|
263
|
+
if (normalized.startsWith("suiprivkey")) {
|
|
264
|
+
return Ed25519Keypair.fromSecretKey(normalized);
|
|
265
|
+
}
|
|
266
|
+
const bytes = decodePrivateKeyBytes(normalized);
|
|
267
|
+
const secretKey = bytes.length === 64 ? bytes.slice(0, 32) : bytes;
|
|
268
|
+
if (secretKey.length !== 32) {
|
|
269
|
+
throw new Error("Sui private key must be a 32-byte seed, 64-byte keypair, or suiprivkey string.");
|
|
270
|
+
}
|
|
271
|
+
return Ed25519Keypair.fromSecretKey(secretKey);
|
|
272
|
+
}
|
|
273
|
+
function decodePrivateKeyBytes(privateKey) {
|
|
274
|
+
const hexValue = privateKey.startsWith("0x") ? privateKey.slice(2) : privateKey;
|
|
275
|
+
if (hexValue.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(hexValue)) {
|
|
276
|
+
return hex.decode(hexValue);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return base58.decode(privateKey);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return base64.decode(privateKey);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function getSuiCoinType(accept) {
|
|
286
|
+
const asset = (accept.assetAddress ?? accept.asset)?.trim();
|
|
287
|
+
if (!asset)
|
|
288
|
+
return SUI_NATIVE_COIN_TYPE;
|
|
289
|
+
return isSuiNativeCoin(asset) ? SUI_NATIVE_COIN_TYPE : asset;
|
|
290
|
+
}
|
|
291
|
+
function isSuiNativeCoin(asset) {
|
|
292
|
+
const normalized = asset.toLowerCase();
|
|
293
|
+
return (normalized === SUI_NATIVE_COIN_TYPE.toLowerCase() ||
|
|
294
|
+
normalized === "native" ||
|
|
295
|
+
normalized === "sui" ||
|
|
296
|
+
normalized === "native-sui" ||
|
|
297
|
+
normalized === "native_sui" ||
|
|
298
|
+
normalized === "native:sui");
|
|
299
|
+
}
|
|
300
|
+
function assetsMatch(network, left, right) {
|
|
301
|
+
return normalizeAssetForNetwork(left, network) === normalizeAssetForNetwork(right, network);
|
|
302
|
+
}
|
|
303
|
+
function normalizeAssetForNetwork(asset, network) {
|
|
304
|
+
return network.toLowerCase().startsWith("eip155:") && asset.startsWith("0x") ? asset.toLowerCase() : asset;
|
|
305
|
+
}
|
|
306
|
+
function isRecord(value) {
|
|
307
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
308
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coin-voyage/paykit-headless",
|
|
3
|
+
"description": "Headless CoinVoyage PayKit clients and protocol helpers.",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"private": false,
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"author": "Lars <lars@coinvoyage.io>",
|
|
8
|
+
"homepage": "https://coinvoyage.io",
|
|
9
|
+
"license": "BSD-2-Clause license",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./x402": {
|
|
19
|
+
"import": "./dist/x402/index.js",
|
|
20
|
+
"types": "./dist/x402/index.d.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"coin-voyage",
|
|
30
|
+
"paykit",
|
|
31
|
+
"headless",
|
|
32
|
+
"x402"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@mysten/sui": "2.16.2",
|
|
36
|
+
"@scure/base": "2.2.0",
|
|
37
|
+
"@solana/kit": "6.9.0",
|
|
38
|
+
"@x402/core": "^2.11.0",
|
|
39
|
+
"@x402/evm": "^2.11.0",
|
|
40
|
+
"@x402/fetch": "^2.11.0",
|
|
41
|
+
"@x402/svm": "^2.11.0",
|
|
42
|
+
"viem": "2.41.2"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"watch": "tsc -w -p ./tsconfig.json",
|
|
46
|
+
"build": "tsc --build --force",
|
|
47
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
48
|
+
"release:build": "pnpm clean && pnpm build",
|
|
49
|
+
"pre:release": "pnpm version prerelease --preid=beta",
|
|
50
|
+
"pre:publish": "pnpm publish --access public --tag beta --no-git-checks",
|
|
51
|
+
"pre:steps": "pnpm pre:release && pnpm release:build && pnpm pre:publish",
|
|
52
|
+
"release": "pnpm publish --access public --no-git-checks",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"type-check": "tsc --noEmit"
|
|
55
|
+
}
|
|
56
|
+
}
|