@three-ws/x402-modal 0.2.0
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/LICENSE +180 -0
- package/README.md +306 -0
- package/TUTORIAL.md +281 -0
- package/dist/x402-modal.mjs +1371 -0
- package/dist/x402-modal.mjs.map +7 -0
- package/dist/x402.global.js +353 -0
- package/dist/x402.global.js.map +7 -0
- package/docs/BACKEND.md +163 -0
- package/docs/CONFIGURATION.md +106 -0
- package/docs/PROTOCOL.md +102 -0
- package/package.json +75 -0
- package/src/global.js +64 -0
- package/src/util.js +147 -0
- package/src/x402-modal.js +1446 -0
- package/types/index.d.ts +149 -0
package/docs/PROTOCOL.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# The x402 flow, as this modal drives it
|
|
2
|
+
|
|
3
|
+
A reference for what happens between `pay()` and the resolved receipt. Useful
|
|
4
|
+
when debugging an integration or building the server side.
|
|
5
|
+
|
|
6
|
+
## The four steps
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
┌── 1. DISCOVER ──────────────────────────────────────────────────────────┐
|
|
10
|
+
│ The modal sends the request you described (endpoint/method/body/headers) │
|
|
11
|
+
│ with NO payment. It expects one of: │
|
|
12
|
+
│ • HTTP 402 with a JSON body containing `accepts[]`, or │
|
|
13
|
+
│ • HTTP 401 with a base64-JSON `payment-required` header (MCP 2025-06-18)│
|
|
14
|
+
│ Anything else (200, etc.) is surfaced as an error — pointing the modal at │
|
|
15
|
+
│ a free endpoint must not silently "succeed". │
|
|
16
|
+
└───────────────────────────────────────────────────────────────────────────┘
|
|
17
|
+
│ accepts[] = [{ scheme, network, asset,
|
|
18
|
+
│ payTo, amount, extra… }, …]
|
|
19
|
+
▼
|
|
20
|
+
┌── 2. CONNECT ───────────────────────────────────────────────────────────┐
|
|
21
|
+
│ The modal picks accepts it can satisfy: │
|
|
22
|
+
│ • Solana (`solana:*`) → Phantom (`window.solana` / `window.phantom`) │
|
|
23
|
+
│ • EVM (`eip155:*`) → `window.ethereum` (EIP-3009 entries only; │
|
|
24
|
+
│ Permit2 siblings are skipped) │
|
|
25
|
+
│ >1 viable → wallet picker. Exactly 1 + `autoConnect` → straight through. │
|
|
26
|
+
│ Spending caps (if set) are checked here, before any signature prompt. │
|
|
27
|
+
└───────────────────────────────────────────────────────────────────────────┘
|
|
28
|
+
▼
|
|
29
|
+
┌── 3. AUTHORIZE ─────────────────────────────────────────────────────────┐
|
|
30
|
+
│ Solana: │
|
|
31
|
+
│ POST {apiOrigin}/api/x402-checkout?action=prepare {accept,buyer} │
|
|
32
|
+
│ → tx_base64 (built server-side; fee payer = facilitator) │
|
|
33
|
+
│ Phantom signs the VersionedTransaction │
|
|
34
|
+
│ POST …?action=encode {accept, signed_tx_base64, resource_url, …} │
|
|
35
|
+
│ → x_payment (base64 paymentPayload) │
|
|
36
|
+
│ EVM: │
|
|
37
|
+
│ wallet signs an EIP-3009 TransferWithAuthorization (typed data v4) │
|
|
38
|
+
│ the modal assembles x_payment locally — no backend, no gas, no tx │
|
|
39
|
+
└───────────────────────────────────────────────────────────────────────────┘
|
|
40
|
+
▼
|
|
41
|
+
┌── 4. VERIFY & SETTLE ───────────────────────────────────────────────────┐
|
|
42
|
+
│ Re-send the original request with header `X-PAYMENT: <x_payment>`. │
|
|
43
|
+
│ The server VERIFIES the payment, RUNS the work, SETTLES on-chain, and │
|
|
44
|
+
│ returns 200 with an `x-payment-response` header (base64 receipt: │
|
|
45
|
+
│ network, payer, transaction). The modal renders the receipt + result. │
|
|
46
|
+
└───────────────────────────────────────────────────────────────────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Why settlement-after-work makes retries safe
|
|
50
|
+
|
|
51
|
+
x402 servers verify and *settle* the payment only **after** the protected work
|
|
52
|
+
succeeds. So if the merchant returns a transient `429` (an upstream rate-limit
|
|
53
|
+
hit *before* settlement), the exact same signed `X-PAYMENT` can be re-sent once
|
|
54
|
+
the window resets with **no risk of a double charge**. The modal auto-retries a
|
|
55
|
+
`429` up to twice, honoring `Retry-After`, with a live countdown, before falling
|
|
56
|
+
back to a manual **Try again**.
|
|
57
|
+
|
|
58
|
+
## SIWX re-entry (optional)
|
|
59
|
+
|
|
60
|
+
If the `402` body includes an `extensions['sign-in-with-x']` block, the modal
|
|
61
|
+
leads with **"Sign in with wallet"**: a buyer who already paid for this resource
|
|
62
|
+
can re-enter by signing a CAIP-122 challenge (submitted via the
|
|
63
|
+
`SIGN-IN-WITH-X` header) instead of paying again. If the server responds that
|
|
64
|
+
this wallet hasn't actually paid (`401/402` + `siwx_not_paid`), the modal
|
|
65
|
+
transparently falls back to the normal payment flow with a one-line notice.
|
|
66
|
+
|
|
67
|
+
The message string the modal signs is byte-compatible with the
|
|
68
|
+
[siwe](https://github.com/spruceid/siwe) (EVM) and SIWS (Solana) formats so the
|
|
69
|
+
server's recovered signer matches `payload.address`. See `buildSiwxMessage` in
|
|
70
|
+
`src/util.js`.
|
|
71
|
+
|
|
72
|
+
## ERC-8021 builder codes
|
|
73
|
+
|
|
74
|
+
When the `402` declares a builder code under
|
|
75
|
+
`extensions['builder-code'].info.a`, the modal echoes it back (anti-tamper: the
|
|
76
|
+
server checks the echoed `a` equals what it declared) and self-attributes its
|
|
77
|
+
own wallet (`w`) and service (`s`) codes from
|
|
78
|
+
[`configure({ builderCode })`](./CONFIGURATION.md). Set `builderCode: null` to
|
|
79
|
+
disable the echo.
|
|
80
|
+
|
|
81
|
+
## The `accept` object
|
|
82
|
+
|
|
83
|
+
| field | meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `scheme` | payment scheme — `exact` for a fixed price |
|
|
86
|
+
| `network` | CAIP-2 id: `solana:<genesis>` or `eip155:<chainId>` |
|
|
87
|
+
| `asset` | token: SPL mint (Solana) or ERC-20 address (EVM) |
|
|
88
|
+
| `payTo` | recipient wallet |
|
|
89
|
+
| `amount` | atomic amount (the modal also reads spec-canonical `maxAmountRequired`) |
|
|
90
|
+
| `maxTimeoutSeconds` | EVM EIP-3009 `validBefore` window (default 600) |
|
|
91
|
+
| `extra.name` | display + stablecoin detection (`USDC`, `USDT`, `DAI`…) |
|
|
92
|
+
| `extra.decimals` | token decimals (default 6) |
|
|
93
|
+
| `extra.version` | EVM EIP-712 domain version (Base USDC = `"2"`) |
|
|
94
|
+
| `extra.feePayer` | Solana facilitator fee-payer pubkey (Solana only) |
|
|
95
|
+
| `extra.assetTransferMethod` | `eip3009` (default) or `permit2` (skipped by this modal) |
|
|
96
|
+
|
|
97
|
+
## Spec references
|
|
98
|
+
|
|
99
|
+
- x402 — <https://x402.org>
|
|
100
|
+
- CAIP-2 network ids — <https://chainagnostic.org/CAIPs/caip-2>
|
|
101
|
+
- EIP-3009 `transferWithAuthorization` — <https://eips.ethereum.org/EIPS/eip-3009>
|
|
102
|
+
- CAIP-122 / Sign-In-With-X — <https://chainagnostic.org/CAIPs/caip-122>
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@three-ws/x402-modal",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A drop-in, dependency-free payment modal for any x402 paid endpoint. One script tag turns a 402 challenge into a polished checkout: wallet connect (Phantom on Solana, MetaMask/EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, spending caps, and a receipt — vanilla JS, no bundler required. Powered by three.ws.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"x402",
|
|
7
|
+
"payments",
|
|
8
|
+
"micropayments",
|
|
9
|
+
"usdc",
|
|
10
|
+
"stablecoin",
|
|
11
|
+
"solana",
|
|
12
|
+
"base",
|
|
13
|
+
"evm",
|
|
14
|
+
"eip-3009",
|
|
15
|
+
"eip3009",
|
|
16
|
+
"phantom",
|
|
17
|
+
"metamask",
|
|
18
|
+
"paywall",
|
|
19
|
+
"pay-per-call",
|
|
20
|
+
"checkout",
|
|
21
|
+
"web3",
|
|
22
|
+
"siwx",
|
|
23
|
+
"agent-payments",
|
|
24
|
+
"402"
|
|
25
|
+
],
|
|
26
|
+
"author": "three.ws <support@three.ws> (https://three.ws)",
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"homepage": "https://three.ws",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/nirholas/three.ws.git",
|
|
32
|
+
"directory": "x402-modal-sdk"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/nirholas/three.ws/issues"
|
|
36
|
+
},
|
|
37
|
+
"type": "module",
|
|
38
|
+
"main": "./dist/x402-modal.mjs",
|
|
39
|
+
"module": "./dist/x402-modal.mjs",
|
|
40
|
+
"unpkg": "./dist/x402.global.js",
|
|
41
|
+
"jsdelivr": "./dist/x402.global.js",
|
|
42
|
+
"types": "./types/index.d.ts",
|
|
43
|
+
"exports": {
|
|
44
|
+
".": {
|
|
45
|
+
"types": "./types/index.d.ts",
|
|
46
|
+
"import": "./dist/x402-modal.mjs",
|
|
47
|
+
"default": "./dist/x402-modal.mjs"
|
|
48
|
+
},
|
|
49
|
+
"./global": "./dist/x402.global.js",
|
|
50
|
+
"./src": "./src/x402-modal.js"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"dist",
|
|
54
|
+
"src",
|
|
55
|
+
"types",
|
|
56
|
+
"docs",
|
|
57
|
+
"README.md",
|
|
58
|
+
"TUTORIAL.md",
|
|
59
|
+
"LICENSE"
|
|
60
|
+
],
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "node build.mjs",
|
|
63
|
+
"prepublishOnly": "node build.mjs",
|
|
64
|
+
"test": "node --test \"test/*.test.js\""
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"esbuild": "^0.21.0"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=18"
|
|
71
|
+
},
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/global.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @three-ws/x402-modal — global / CDN entry.
|
|
2
|
+
//
|
|
3
|
+
// This is the side-effectful build that ships as the drop-in <script>. It:
|
|
4
|
+
// 1. reads `data-x402-*` config off its own <script> tag (apiOrigin, brand,
|
|
5
|
+
// builderCode) so a self-hosted deployment can repoint the backend without
|
|
6
|
+
// a line of JS,
|
|
7
|
+
// 2. binds every `[data-x402-endpoint]` element and re-scans the DOM as
|
|
8
|
+
// merchants inject buttons,
|
|
9
|
+
// 3. exposes `window.X402 = { pay, init, configure, version }`.
|
|
10
|
+
//
|
|
11
|
+
// For bundler / npm consumers, import the side-effect-free core instead:
|
|
12
|
+
// import { pay, configure } from '@three-ws/x402-modal';
|
|
13
|
+
|
|
14
|
+
import { pay, init, configure, version } from './x402-modal.js';
|
|
15
|
+
|
|
16
|
+
// Pull optional config off the script tag, e.g.
|
|
17
|
+
// <script src=".../x402.global.js"
|
|
18
|
+
// data-x402-api-origin="https://pay.example.com"
|
|
19
|
+
// data-x402-brand-label="Powered by Acme"
|
|
20
|
+
// data-x402-brand-href="https://acme.com"
|
|
21
|
+
// data-x402-builder-wallet="acme"
|
|
22
|
+
// data-x402-builder-service="acme_checkout"></script>
|
|
23
|
+
function readScriptConfig() {
|
|
24
|
+
if (typeof document === 'undefined') return;
|
|
25
|
+
const el = document.currentScript || document.querySelector('script[src*="x402.global"], script[src*="/x402.js"]');
|
|
26
|
+
const ds = el?.dataset;
|
|
27
|
+
if (!ds) return;
|
|
28
|
+
const cfg = {};
|
|
29
|
+
if (ds.x402ApiOrigin !== undefined) cfg.apiOrigin = ds.x402ApiOrigin;
|
|
30
|
+
if (ds.x402BrandLabel !== undefined || ds.x402BrandHref !== undefined) {
|
|
31
|
+
cfg.brand = {};
|
|
32
|
+
if (ds.x402BrandLabel !== undefined) cfg.brand.label = ds.x402BrandLabel;
|
|
33
|
+
if (ds.x402BrandHref !== undefined) cfg.brand.href = ds.x402BrandHref;
|
|
34
|
+
}
|
|
35
|
+
if (ds.x402BuilderDisable === 'true' || ds.x402BuilderDisable === '') {
|
|
36
|
+
cfg.builderCode = null;
|
|
37
|
+
} else if (ds.x402BuilderWallet !== undefined || ds.x402BuilderService !== undefined) {
|
|
38
|
+
cfg.builderCode = {};
|
|
39
|
+
if (ds.x402BuilderWallet !== undefined) cfg.builderCode.wallet = ds.x402BuilderWallet;
|
|
40
|
+
if (ds.x402BuilderService !== undefined) cfg.builderCode.service = ds.x402BuilderService;
|
|
41
|
+
}
|
|
42
|
+
if (ds.x402SolanaWeb3Url) cfg.solanaWeb3Url = ds.x402SolanaWeb3Url;
|
|
43
|
+
if (ds.x402NobleHashesUrl) cfg.nobleHashesUrl = ds.x402NobleHashesUrl;
|
|
44
|
+
if (Object.keys(cfg).length) configure(cfg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof document !== 'undefined') {
|
|
48
|
+
readScriptConfig();
|
|
49
|
+
if (document.readyState === 'loading') {
|
|
50
|
+
document.addEventListener('DOMContentLoaded', init, { once: true });
|
|
51
|
+
} else {
|
|
52
|
+
init();
|
|
53
|
+
}
|
|
54
|
+
// Re-scan when merchants dynamically inject buttons.
|
|
55
|
+
const mo = new MutationObserver(() => init());
|
|
56
|
+
mo.observe(document.documentElement, { childList: true, subtree: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Expose to merchants' inline scripts.
|
|
60
|
+
if (typeof window !== 'undefined') {
|
|
61
|
+
window.X402 = Object.freeze({ pay, init, configure, version });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { pay, init, configure, version };
|
package/src/util.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Pure, browser-API-free helpers shared by the modal. Kept in their own module
|
|
2
|
+
// so they can be unit-tested in Node without a DOM, and so the core stays lean.
|
|
3
|
+
|
|
4
|
+
// USDC EIP-3009 typed-data sig works against Base USDC. The domain `version`
|
|
5
|
+
// must match the on-chain `EIP712_DOMAIN_SEPARATOR_VERSION` — Base USDC is "2".
|
|
6
|
+
export const EVM_NETWORKS = {
|
|
7
|
+
'eip155:8453': { chainId: 8453, name: 'Base', explorer: 'https://basescan.org/tx/' },
|
|
8
|
+
'eip155:84532': { chainId: 84532, name: 'Base Sepolia', explorer: 'https://sepolia.basescan.org/tx/' },
|
|
9
|
+
'eip155:42161': { chainId: 42161, name: 'Arbitrum', explorer: 'https://arbiscan.io/tx/' },
|
|
10
|
+
'eip155:10': { chainId: 10, name: 'Optimism', explorer: 'https://optimistic.etherscan.io/tx/' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Stablecoins whose atomics are already 6-decimal USD-pegged (used by caps).
|
|
14
|
+
export const STABLE_NAMES = new Set([
|
|
15
|
+
'usdc', 'usd coin', 'usdt', 'tether', 'binance-peg usd coin', 'dai',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
// Normalize a single 402 `accept` entry. The x402 spec's canonical atomic-price
|
|
19
|
+
// field is `maxAmountRequired`; some merchants emit `amount`. We read `amount`
|
|
20
|
+
// everywhere downstream, so coerce here once. Without this a spec-compliant
|
|
21
|
+
// merchant yields `accept.amount === undefined` → "NaN USDC".
|
|
22
|
+
export function normalizeAccept(accept) {
|
|
23
|
+
if (!accept || typeof accept !== 'object') return accept;
|
|
24
|
+
const amount = accept.amount ?? accept.maxAmountRequired;
|
|
25
|
+
return amount != null && accept.amount == null ? { ...accept, amount: String(amount) } : accept;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSolanaNetwork(net) {
|
|
29
|
+
return typeof net === 'string' && (net === 'solana' || net.startsWith('solana:'));
|
|
30
|
+
}
|
|
31
|
+
export function isEvmNetwork(net) {
|
|
32
|
+
return typeof net === 'string' && net.startsWith('eip155:');
|
|
33
|
+
}
|
|
34
|
+
// The modal only signs EIP-3009 transferWithAuthorization for EVM. When the
|
|
35
|
+
// server publishes both an EIP-3009 entry and a Permit2 sibling (the
|
|
36
|
+
// gas-sponsoring path), pick the EIP-3009 one — the sibling carries
|
|
37
|
+
// `extra.assetTransferMethod === 'permit2'`.
|
|
38
|
+
export function isEip3009Accept(accept) {
|
|
39
|
+
if (!isEvmNetwork(accept?.network)) return false;
|
|
40
|
+
const method = accept?.extra?.assetTransferMethod;
|
|
41
|
+
return !method || method === 'eip3009';
|
|
42
|
+
}
|
|
43
|
+
export function networkLabel(net, accept) {
|
|
44
|
+
if (isSolanaNetwork(net)) return 'Solana';
|
|
45
|
+
const meta = EVM_NETWORKS[net];
|
|
46
|
+
return meta?.name || accept?.extra?.name || net;
|
|
47
|
+
}
|
|
48
|
+
export function explorerUrl(net, tx) {
|
|
49
|
+
if (!tx) return null;
|
|
50
|
+
if (isSolanaNetwork(net)) return `https://solscan.io/tx/${tx}`;
|
|
51
|
+
const meta = EVM_NETWORKS[net];
|
|
52
|
+
return meta ? `${meta.explorer}${tx}` : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatAmount(rawAtomics, decimals = 6) {
|
|
56
|
+
const n = Number(rawAtomics) / 10 ** decimals;
|
|
57
|
+
if (n < 0.01) return n.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
|
|
58
|
+
if (n < 1) return n.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
|
|
59
|
+
return n.toFixed(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function b64encode(obj) {
|
|
63
|
+
const json = JSON.stringify(obj);
|
|
64
|
+
if (typeof Buffer !== 'undefined') return Buffer.from(json, 'utf8').toString('base64');
|
|
65
|
+
return btoa(unescape(encodeURIComponent(json)));
|
|
66
|
+
}
|
|
67
|
+
export function b64decode(str) {
|
|
68
|
+
if (!str) return null;
|
|
69
|
+
try {
|
|
70
|
+
const bin = typeof Buffer !== 'undefined' ? Buffer.from(str, 'base64').toString('utf8') : decodeURIComponent(escape(atob(str)));
|
|
71
|
+
return JSON.parse(bin);
|
|
72
|
+
} catch (_) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Base58 (Bitcoin alphabet) — Solana's encoding for addresses and signatures.
|
|
78
|
+
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
79
|
+
export function base58encode(bytes) {
|
|
80
|
+
if (!bytes || bytes.length === 0) return '';
|
|
81
|
+
let leadingZeros = 0;
|
|
82
|
+
while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) leadingZeros++;
|
|
83
|
+
let n = 0n;
|
|
84
|
+
for (let i = 0; i < bytes.length; i++) n = (n << 8n) | BigInt(bytes[i]);
|
|
85
|
+
let out = '';
|
|
86
|
+
while (n > 0n) {
|
|
87
|
+
out = BASE58_ALPHABET[Number(n % 58n)] + out;
|
|
88
|
+
n /= 58n;
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < leadingZeros; i++) out = BASE58_ALPHABET[0] + out;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Convert an asset's atomic amount to micro-USD for cap accounting. Stablecoins
|
|
95
|
+
// pass through (scaled to 6 decimals); non-stable assets pass through atomic and
|
|
96
|
+
// must be capped server-side (the browser modal fetches no prices).
|
|
97
|
+
export function toMicroUsd(amount, accept) {
|
|
98
|
+
const atomic = BigInt(amount);
|
|
99
|
+
const decimals = Number(accept?.extra?.decimals ?? 6);
|
|
100
|
+
const name = String(accept?.extra?.name || '').toLowerCase();
|
|
101
|
+
if (STABLE_NAMES.has(name)) {
|
|
102
|
+
if (decimals === 6) return atomic;
|
|
103
|
+
if (decimals > 6) return atomic / 10n ** BigInt(decimals - 6);
|
|
104
|
+
return atomic * 10n ** BigInt(6 - decimals);
|
|
105
|
+
}
|
|
106
|
+
return atomic;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function spendBuckets(timestamp = Date.now()) {
|
|
110
|
+
const hour = Math.floor(timestamp / 3_600_000);
|
|
111
|
+
const day = Math.floor(timestamp / 86_400_000);
|
|
112
|
+
return { hour, day };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Build the CAIP-122 SIWX message string. The server rebuilds the same string
|
|
116
|
+
// from payload fields when verifying — any line-by-line drift makes the
|
|
117
|
+
// recovered signer mismatch payload.address and the signature is rejected.
|
|
118
|
+
export function buildSiwxMessage(info, chain, address) {
|
|
119
|
+
const isEvm = chain.type === 'eip191';
|
|
120
|
+
const accountHeader = isEvm
|
|
121
|
+
? `${info.domain} wants you to sign in with your Ethereum account:`
|
|
122
|
+
: `${info.domain} wants you to sign in with your Solana account:`;
|
|
123
|
+
const [, chainTail = ''] = String(chain.chainId).split(':');
|
|
124
|
+
const chainRef = isEvm ? String(parseInt(chainTail, 10)) : chainTail;
|
|
125
|
+
|
|
126
|
+
const lines = [accountHeader, address, ''];
|
|
127
|
+
if (info.statement) {
|
|
128
|
+
lines.push(info.statement, '');
|
|
129
|
+
} else if (isEvm) {
|
|
130
|
+
// siwe's prepareMessage() reserves the statement block even when absent,
|
|
131
|
+
// emitting an extra blank line. SIWS does not.
|
|
132
|
+
lines.push('');
|
|
133
|
+
}
|
|
134
|
+
lines.push(`URI: ${info.uri}`);
|
|
135
|
+
lines.push(`Version: ${info.version || '1'}`);
|
|
136
|
+
lines.push(`Chain ID: ${chainRef}`);
|
|
137
|
+
lines.push(`Nonce: ${info.nonce}`);
|
|
138
|
+
lines.push(`Issued At: ${info.issuedAt}`);
|
|
139
|
+
if (info.expirationTime) lines.push(`Expiration Time: ${info.expirationTime}`);
|
|
140
|
+
if (info.notBefore) lines.push(`Not Before: ${info.notBefore}`);
|
|
141
|
+
if (info.requestId !== undefined && info.requestId !== null) lines.push(`Request ID: ${info.requestId}`);
|
|
142
|
+
if (Array.isArray(info.resources) && info.resources.length) {
|
|
143
|
+
lines.push('Resources:');
|
|
144
|
+
for (const r of info.resources) lines.push(`- ${r}`);
|
|
145
|
+
}
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|