@opaqueprivacy/merchant-sdk 0.1.0-alpha.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/README.md +140 -0
- package/dist/browser.d.ts +34 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +87 -0
- package/dist/browser.js.map +1 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +117 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/receipt.d.ts +17 -0
- package/dist/receipt.d.ts.map +1 -0
- package/dist/receipt.js +47 -0
- package/dist/receipt.js.map +1 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/browser.ts +119 -0
- package/src/client.ts +154 -0
- package/src/index.ts +13 -0
- package/src/receipt.ts +62 -0
- package/src/types.ts +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @opaqueprivacy/merchant-sdk
|
|
2
|
+
|
|
3
|
+
Drop-in SDK for accepting **private** crypto payments on your site. The amount and sender are hidden on-chain; you still get a signed receipt and a webhook when payment lands.
|
|
4
|
+
|
|
5
|
+
> **v1 alpha.** APIs may change. Don't rely on this for production revenue yet — treat early integrations as demos. File issues at the [main repo](https://github.com/sorrowzzz/OpaquePrivacy).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @opaqueprivacy/merchant-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Get an API key
|
|
16
|
+
|
|
17
|
+
1. Go to the Opaque dashboard → **Merchant SDK** tab.
|
|
18
|
+
2. Click **Generate key** and copy the `op_live_...` value. It's shown exactly once.
|
|
19
|
+
3. Paste it into your server's env as `OPAQUE_API_KEY`.
|
|
20
|
+
|
|
21
|
+
You also need to have made at least one deposit on Opaque from the wallet you generated the key under — that provisions the intermediate wallet that receives payments.
|
|
22
|
+
|
|
23
|
+
## Server: create a checkout
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { OpaquePay } from "@opaqueprivacy/merchant-sdk";
|
|
27
|
+
|
|
28
|
+
const opaque = new OpaquePay({ apiKey: process.env.OPAQUE_API_KEY! });
|
|
29
|
+
|
|
30
|
+
const checkout = await opaque.createCheckout({
|
|
31
|
+
amount: 9.99,
|
|
32
|
+
serviceId: "order_1234", // your own order ID
|
|
33
|
+
expirationHours: 1,
|
|
34
|
+
callbackUrl: "https://shop.com/webhooks/opaque",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Send the customer to checkout.url, or hand the URL to the browser widget.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Browser: drop-in widget
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
<button id="pay">Pay with Opaque</button>
|
|
44
|
+
<script type="module">
|
|
45
|
+
import { openCheckout } from "@opaqueprivacy/merchant-sdk/browser";
|
|
46
|
+
|
|
47
|
+
document.getElementById("pay").onclick = async () => {
|
|
48
|
+
// Ask your server to create a checkout, then open the URL.
|
|
49
|
+
const { url } = await fetch("/api/create-checkout", { method: "POST" })
|
|
50
|
+
.then((r) => r.json());
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const result = await openCheckout({ url });
|
|
54
|
+
console.log("paid", result.transactionSignature);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// CheckoutCancelledError if the user closed the popup
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
</script>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The widget opens `/pay/<id>` in a popup, listens for a `postMessage` from the Opaque gateway, and resolves with `{ requestId, transactionSignature, amount, token }` when the on-chain settle lands.
|
|
63
|
+
|
|
64
|
+
## Server: verify the webhook
|
|
65
|
+
|
|
66
|
+
When the payment lands, Opaque POSTs a JSON event to your `callbackUrl` and signs it with an ed25519 key. **Always verify** — anyone could POST anything to your endpoint, but only Opaque can sign with the gateway key.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import express from "express";
|
|
70
|
+
import { OpaquePay } from "@opaqueprivacy/merchant-sdk";
|
|
71
|
+
|
|
72
|
+
const opaque = new OpaquePay({ apiKey: process.env.OPAQUE_API_KEY! });
|
|
73
|
+
const app = express();
|
|
74
|
+
|
|
75
|
+
// Capture the raw body — needed for signature verification.
|
|
76
|
+
app.post(
|
|
77
|
+
"/webhooks/opaque",
|
|
78
|
+
express.raw({ type: "*/*" }),
|
|
79
|
+
(req, res) => {
|
|
80
|
+
const sig = req.headers["x-opaque-receipt-signature"] as string;
|
|
81
|
+
try {
|
|
82
|
+
const event = opaque.verifyWebhook(req.body.toString("utf8"), sig);
|
|
83
|
+
// event.requestId, event.amount, event.transactionSignature, …
|
|
84
|
+
// Mark order paid in your DB here.
|
|
85
|
+
res.json({ ok: true });
|
|
86
|
+
} catch (err) {
|
|
87
|
+
res.status(400).json({ error: (err as Error).message });
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The webhook is **best-effort, single attempt** in v1 alpha. If your server is down when the gateway tries, the payment still settles on-chain — you can pull the latest state with `opaque.retrieveCheckout(id)` and reconcile.
|
|
94
|
+
|
|
95
|
+
## Reference
|
|
96
|
+
|
|
97
|
+
### `new OpaquePay({ apiKey, baseUrl?, fetch? })`
|
|
98
|
+
|
|
99
|
+
- `apiKey` — required. `op_live_...` from the dashboard.
|
|
100
|
+
- `baseUrl` — defaults to `https://opaque.privacy`.
|
|
101
|
+
- `fetch` — optional custom fetch (defaults to global).
|
|
102
|
+
|
|
103
|
+
### `opaque.createCheckout(input)`
|
|
104
|
+
|
|
105
|
+
| Field | Type | Notes |
|
|
106
|
+
| ----------------- | -------- | -------------------------------------------------- |
|
|
107
|
+
| `amount` | `number` | Human units (`9.99` = 9.99 USDC). Required. |
|
|
108
|
+
| `serviceId` | `string` | Your own order/cart ID. Max 200 chars. Required. |
|
|
109
|
+
| `expirationHours` | `number` | Default 24, max 720. |
|
|
110
|
+
| `callbackUrl` | `string` | https:// only. Required if you want a webhook. |
|
|
111
|
+
|
|
112
|
+
Returns a `Checkout` with `id`, `url`, `status`, `expiresAt`, etc.
|
|
113
|
+
|
|
114
|
+
### `opaque.retrieveCheckout(id)`
|
|
115
|
+
|
|
116
|
+
Returns the latest state of a checkout. Use this to reconcile if a webhook is missed.
|
|
117
|
+
|
|
118
|
+
### `opaque.verifyWebhook(rawBody, signatureHeader)`
|
|
119
|
+
|
|
120
|
+
Verifies the ed25519 receipt signature against the gateway's public key. Returns the parsed `WebhookEvent` if valid, throws otherwise.
|
|
121
|
+
|
|
122
|
+
### `openCheckout({ url, width?, height?, timeoutMs? })` (browser)
|
|
123
|
+
|
|
124
|
+
Opens the checkout URL in a popup, resolves when the customer pays. Rejects with `CheckoutCancelledError` if they close the window.
|
|
125
|
+
|
|
126
|
+
## Example
|
|
127
|
+
|
|
128
|
+
A full Express app is in [`examples/express/`](./examples/express). Run it with:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
OPAQUE_API_KEY=op_live_xxx node examples/express/index.js
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then visit `http://localhost:3000/pay.html`.
|
|
135
|
+
|
|
136
|
+
## Security notes
|
|
137
|
+
|
|
138
|
+
- **Never embed your API key in client-side code.** Always create checkouts from your server.
|
|
139
|
+
- **Always verify webhook signatures.** Webhooks come from the public internet — only the signature proves they came from Opaque.
|
|
140
|
+
- The plaintext API key is shown once at creation. Revoke and rotate from the dashboard.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side helper for opening an Opaque Pay checkout as a popup and
|
|
3
|
+
* resolving when the payment lands. The popup posts a message back via
|
|
4
|
+
* window.opener.postMessage — see src/pages/Pay.tsx on the gateway.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { openCheckout } from "@opaqueprivacy/merchant-sdk/browser";
|
|
8
|
+
* const result = await openCheckout({ url: checkout.url });
|
|
9
|
+
* // result.transactionSignature is the on-chain proof of payment.
|
|
10
|
+
*/
|
|
11
|
+
export interface OpenCheckoutOptions {
|
|
12
|
+
/** Checkout URL returned by OpaquePay.createCheckout(). */
|
|
13
|
+
url: string;
|
|
14
|
+
/** Popup width, default 480. */
|
|
15
|
+
width?: number;
|
|
16
|
+
/** Popup height, default 720. */
|
|
17
|
+
height?: number;
|
|
18
|
+
/** Abort if no payment within N ms. Default: no timeout. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface CheckoutResult {
|
|
22
|
+
requestId: string;
|
|
23
|
+
transactionSignature: string | null;
|
|
24
|
+
amount: number;
|
|
25
|
+
token: "USDC";
|
|
26
|
+
}
|
|
27
|
+
export declare class CheckoutCancelledError extends Error {
|
|
28
|
+
constructor();
|
|
29
|
+
}
|
|
30
|
+
export declare class CheckoutTimeoutError extends Error {
|
|
31
|
+
constructor();
|
|
32
|
+
}
|
|
33
|
+
export declare function openCheckout(opts: OpenCheckoutOptions): Promise<CheckoutResult>;
|
|
34
|
+
//# sourceMappingURL=browser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,WAAW,mBAAmB;IAClC,2DAA2D;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,sBAAuB,SAAQ,KAAK;;CAKhD;AAED,qBAAa,oBAAqB,SAAQ,KAAK;;CAK9C;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,cAAc,CAAC,CAyEzB"}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side helper for opening an Opaque Pay checkout as a popup and
|
|
3
|
+
* resolving when the payment lands. The popup posts a message back via
|
|
4
|
+
* window.opener.postMessage — see src/pages/Pay.tsx on the gateway.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { openCheckout } from "@opaqueprivacy/merchant-sdk/browser";
|
|
8
|
+
* const result = await openCheckout({ url: checkout.url });
|
|
9
|
+
* // result.transactionSignature is the on-chain proof of payment.
|
|
10
|
+
*/
|
|
11
|
+
export class CheckoutCancelledError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Checkout window was closed before payment completed");
|
|
14
|
+
this.name = "CheckoutCancelledError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class CheckoutTimeoutError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super("Checkout timed out");
|
|
20
|
+
this.name = "CheckoutTimeoutError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function openCheckout(opts) {
|
|
24
|
+
const width = opts.width ?? 480;
|
|
25
|
+
const height = opts.height ?? 720;
|
|
26
|
+
const left = Math.max(0, Math.floor((window.screen.width - width) / 2));
|
|
27
|
+
const top = Math.max(0, Math.floor((window.screen.height - height) / 2));
|
|
28
|
+
const features = `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`;
|
|
29
|
+
const popup = window.open(opts.url, "opaque-pay-checkout", features);
|
|
30
|
+
if (!popup) {
|
|
31
|
+
return Promise.reject(new Error("Popup blocked. Open the URL in a new tab or whitelist the domain."));
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let done = false;
|
|
35
|
+
const cleanup = () => {
|
|
36
|
+
done = true;
|
|
37
|
+
window.removeEventListener("message", onMessage);
|
|
38
|
+
window.clearInterval(pollClosed);
|
|
39
|
+
if (timeoutHandle)
|
|
40
|
+
window.clearTimeout(timeoutHandle);
|
|
41
|
+
};
|
|
42
|
+
const onMessage = (ev) => {
|
|
43
|
+
const data = ev.data;
|
|
44
|
+
if (!data || data.type !== "opaque-pay-success")
|
|
45
|
+
return;
|
|
46
|
+
if (!data.requestId)
|
|
47
|
+
return;
|
|
48
|
+
cleanup();
|
|
49
|
+
try {
|
|
50
|
+
popup.close();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// popup may already be closed cross-origin; ignore.
|
|
54
|
+
}
|
|
55
|
+
resolve({
|
|
56
|
+
requestId: data.requestId,
|
|
57
|
+
transactionSignature: data.transactionSignature ?? null,
|
|
58
|
+
amount: data.amount ?? 0,
|
|
59
|
+
token: data.token ?? "USDC",
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
window.addEventListener("message", onMessage);
|
|
63
|
+
const pollClosed = window.setInterval(() => {
|
|
64
|
+
if (done)
|
|
65
|
+
return;
|
|
66
|
+
if (popup.closed) {
|
|
67
|
+
cleanup();
|
|
68
|
+
reject(new CheckoutCancelledError());
|
|
69
|
+
}
|
|
70
|
+
}, 500);
|
|
71
|
+
const timeoutHandle = opts.timeoutMs
|
|
72
|
+
? window.setTimeout(() => {
|
|
73
|
+
if (done)
|
|
74
|
+
return;
|
|
75
|
+
cleanup();
|
|
76
|
+
try {
|
|
77
|
+
popup.close();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
reject(new CheckoutTimeoutError());
|
|
83
|
+
}, opts.timeoutMs)
|
|
84
|
+
: null;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=browser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.js","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Checkout, CreateCheckoutInput, WebhookEvent } from "./types.js";
|
|
2
|
+
export interface OpaquePayOptions {
|
|
3
|
+
/** API key generated in the Opaque dashboard (op_live_...). */
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/** Base URL of the Opaque gateway. Defaults to production. */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
/** Optional fetch implementation (e.g. for testing or non-global fetch). */
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare class OpaquePayError extends Error {
|
|
11
|
+
status: number;
|
|
12
|
+
body: unknown;
|
|
13
|
+
constructor(status: number, message: string, body: unknown);
|
|
14
|
+
}
|
|
15
|
+
export declare class OpaquePay {
|
|
16
|
+
private apiKey;
|
|
17
|
+
private baseUrl;
|
|
18
|
+
private fetchImpl;
|
|
19
|
+
constructor(opts: OpaquePayOptions);
|
|
20
|
+
/**
|
|
21
|
+
* Create a private checkout. Send the customer to `checkout.url` —
|
|
22
|
+
* either by redirecting, or by passing the URL to the browser widget.
|
|
23
|
+
*/
|
|
24
|
+
createCheckout(input: CreateCheckoutInput): Promise<Checkout>;
|
|
25
|
+
/** Fetch the latest state of a checkout (status, payer, tx sig). */
|
|
26
|
+
retrieveCheckout(id: string): Promise<Checkout & {
|
|
27
|
+
payerWallet?: string;
|
|
28
|
+
transactionSignature?: string;
|
|
29
|
+
paidAt?: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Verify a webhook payload using the receipt signature the gateway
|
|
33
|
+
* sends alongside it. Returns the parsed event if valid, throws if
|
|
34
|
+
* the signature doesn't verify.
|
|
35
|
+
*
|
|
36
|
+
* Pass the raw request body (string) and the signature from the
|
|
37
|
+
* X-Opaque-Receipt-Signature header.
|
|
38
|
+
*/
|
|
39
|
+
verifyWebhook(rawBody: string, signatureHexHeader: string | undefined): WebhookEvent;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,mBAAmB,EACnB,YAAY,EACb,MAAM,YAAY,CAAC;AAGpB,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,MAAM,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,qBAAa,cAAe,SAAQ,KAAK;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;gBACF,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;CAM3D;AAED,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAe;gBAEpB,IAAI,EAAE,gBAAgB;IAmBlC;;;OAGG;IACG,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC;IA4CnE,oEAAoE;IAC9D,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG;QACrD,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;IAeF;;;;;;;OAOG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;CAkBrF"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { verifyReceipt } from "./receipt.js";
|
|
2
|
+
export class OpaquePayError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(status, message, body) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "OpaquePayError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class OpaquePay {
|
|
13
|
+
apiKey;
|
|
14
|
+
baseUrl;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
if (!opts.apiKey) {
|
|
18
|
+
throw new Error("OpaquePay: apiKey is required");
|
|
19
|
+
}
|
|
20
|
+
if (!opts.apiKey.startsWith("op_live_")) {
|
|
21
|
+
throw new Error("OpaquePay: apiKey must start with op_live_ (generate one in your dashboard)");
|
|
22
|
+
}
|
|
23
|
+
this.apiKey = opts.apiKey;
|
|
24
|
+
this.baseUrl = (opts.baseUrl ?? "https://opaque.privacy").replace(/\/$/, "");
|
|
25
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
26
|
+
if (!this.fetchImpl) {
|
|
27
|
+
throw new Error("OpaquePay: no fetch implementation found. Pass one via options.fetch.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a private checkout. Send the customer to `checkout.url` —
|
|
32
|
+
* either by redirecting, or by passing the URL to the browser widget.
|
|
33
|
+
*/
|
|
34
|
+
async createCheckout(input) {
|
|
35
|
+
if (!(input.amount > 0)) {
|
|
36
|
+
throw new Error("createCheckout: amount must be > 0");
|
|
37
|
+
}
|
|
38
|
+
if (!input.serviceId?.trim()) {
|
|
39
|
+
throw new Error("createCheckout: serviceId is required");
|
|
40
|
+
}
|
|
41
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/x402/create`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
amount: input.amount,
|
|
49
|
+
serviceId: input.serviceId,
|
|
50
|
+
expirationHours: input.expirationHours,
|
|
51
|
+
callbackUrl: input.callbackUrl,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
const json = await safeJson(res);
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
throw new OpaquePayError(res.status, json?.error ?? `Failed to create checkout (${res.status})`, json);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
id: json.id,
|
|
60
|
+
url: json.url,
|
|
61
|
+
amount: json.amount,
|
|
62
|
+
token: json.token,
|
|
63
|
+
serviceId: json.serviceId,
|
|
64
|
+
status: json.status,
|
|
65
|
+
expiresAt: json.expiresAt,
|
|
66
|
+
createdAt: json.createdAt,
|
|
67
|
+
callbackUrl: json.callbackUrl ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Fetch the latest state of a checkout (status, payer, tx sig). */
|
|
71
|
+
async retrieveCheckout(id) {
|
|
72
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/x402/${id}`, {
|
|
73
|
+
method: "GET",
|
|
74
|
+
});
|
|
75
|
+
const json = await safeJson(res);
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
throw new OpaquePayError(res.status, json?.error ?? `Failed to retrieve checkout (${res.status})`, json);
|
|
78
|
+
}
|
|
79
|
+
return json;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Verify a webhook payload using the receipt signature the gateway
|
|
83
|
+
* sends alongside it. Returns the parsed event if valid, throws if
|
|
84
|
+
* the signature doesn't verify.
|
|
85
|
+
*
|
|
86
|
+
* Pass the raw request body (string) and the signature from the
|
|
87
|
+
* X-Opaque-Receipt-Signature header.
|
|
88
|
+
*/
|
|
89
|
+
verifyWebhook(rawBody, signatureHexHeader) {
|
|
90
|
+
if (!signatureHexHeader) {
|
|
91
|
+
throw new Error("verifyWebhook: missing X-Opaque-Receipt-Signature header");
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(rawBody);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error("verifyWebhook: body is not valid JSON");
|
|
99
|
+
}
|
|
100
|
+
if (parsed.receiptSignatureHex !== signatureHexHeader) {
|
|
101
|
+
throw new Error("verifyWebhook: header signature does not match body");
|
|
102
|
+
}
|
|
103
|
+
if (!verifyReceipt(parsed)) {
|
|
104
|
+
throw new Error("verifyWebhook: signature is invalid");
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function safeJson(res) {
|
|
110
|
+
try {
|
|
111
|
+
return await res.json();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { OpaquePay, OpaquePayError } from "./client.js";
|
|
2
|
+
export type { OpaquePayOptions } from "./client.js";
|
|
3
|
+
export { buildCanonicalReceipt, verifyReceipt, } from "./receipt.js";
|
|
4
|
+
export type { Checkout, CheckoutStatus, CreateCheckoutInput, Token, WebhookEvent, } from "./types.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACxD,YAAY,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EACL,qBAAqB,EACrB,aAAa,GACd,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,QAAQ,EACR,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,YAAY,GACb,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAExD,OAAO,EACL,qBAAqB,EACrB,aAAa,GACd,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify the ed25519 receipt the Opaque gateway signs for every paid
|
|
3
|
+
* checkout. The canonical message must match lib/x402-receipt.ts on the
|
|
4
|
+
* gateway side — if you change one, change both.
|
|
5
|
+
*/
|
|
6
|
+
import type { WebhookEvent } from "./types.js";
|
|
7
|
+
export declare function buildCanonicalReceipt(e: {
|
|
8
|
+
requestId: string;
|
|
9
|
+
merchantWallet: string;
|
|
10
|
+
payerWallet: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
token: "USDC";
|
|
13
|
+
paidAt: Date;
|
|
14
|
+
transactionSignature: string;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function verifyReceipt(event: WebhookEvent): boolean;
|
|
17
|
+
//# sourceMappingURL=receipt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receipt.d.ts","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAQ/C,wBAAgB,qBAAqB,CAAC,CAAC,EAAE;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,IAAI,CAAC;IACb,oBAAoB,EAAE,MAAM,CAAC;CAC9B,GAAG,MAAM,CAWT;AAWD,wBAAgB,aAAa,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAe1D"}
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify the ed25519 receipt the Opaque gateway signs for every paid
|
|
3
|
+
* checkout. The canonical message must match lib/x402-receipt.ts on the
|
|
4
|
+
* gateway side — if you change one, change both.
|
|
5
|
+
*/
|
|
6
|
+
import nacl from "tweetnacl";
|
|
7
|
+
import bs58 from "bs58";
|
|
8
|
+
const RECEIPT_VERSION = "opaque.x402.receipt.v1";
|
|
9
|
+
function formatAmount(n) {
|
|
10
|
+
return Number(n.toFixed(6)).toString();
|
|
11
|
+
}
|
|
12
|
+
export function buildCanonicalReceipt(e) {
|
|
13
|
+
return [
|
|
14
|
+
RECEIPT_VERSION,
|
|
15
|
+
e.requestId,
|
|
16
|
+
e.merchantWallet,
|
|
17
|
+
e.payerWallet,
|
|
18
|
+
formatAmount(e.amount),
|
|
19
|
+
e.token,
|
|
20
|
+
Math.floor(e.paidAt.getTime() / 1000).toString(),
|
|
21
|
+
e.transactionSignature,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
function hexToBytes(hex) {
|
|
25
|
+
if (hex.length % 2 !== 0)
|
|
26
|
+
throw new Error("invalid hex");
|
|
27
|
+
const out = new Uint8Array(hex.length / 2);
|
|
28
|
+
for (let i = 0; i < out.length; i++) {
|
|
29
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export function verifyReceipt(event) {
|
|
34
|
+
const message = new TextEncoder().encode(buildCanonicalReceipt({
|
|
35
|
+
requestId: event.requestId,
|
|
36
|
+
merchantWallet: event.merchantWallet,
|
|
37
|
+
payerWallet: event.payerWallet,
|
|
38
|
+
amount: event.amount,
|
|
39
|
+
token: event.token,
|
|
40
|
+
paidAt: new Date(event.paidAt),
|
|
41
|
+
transactionSignature: event.transactionSignature,
|
|
42
|
+
}));
|
|
43
|
+
const sig = hexToBytes(event.receiptSignatureHex);
|
|
44
|
+
const pubkey = bs58.decode(event.gatewayPublicKey);
|
|
45
|
+
return nacl.sign.detached.verify(message, sig, pubkey);
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=receipt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receipt.js","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,IAAI,MAAM,MAAM,CAAC;AAGxB,MAAM,eAAe,GAAG,wBAAwB,CAAC;AAEjD,SAAS,YAAY,CAAC,CAAS;IAC7B,OAAO,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,CAQrC;IACC,OAAO;QACL,eAAe;QACf,CAAC,CAAC,SAAS;QACX,CAAC,CAAC,cAAc;QAChB,CAAC,CAAC,WAAW;QACb,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC;QACtB,CAAC,CAAC,KAAK;QACP,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,CAAC,QAAQ,EAAE;QAChD,CAAC,CAAC,oBAAoB;KACvB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,aAAa,CAAC,CAAC;IACzD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,GAAG,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACrD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,KAAmB;IAC/C,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CACtC,qBAAqB,CAAC;QACpB,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,cAAc,EAAE,KAAK,CAAC,cAAc;QACpC,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,MAAM,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;QAC9B,oBAAoB,EAAE,KAAK,CAAC,oBAAoB;KACjD,CAAC,CACH,CAAC;IACF,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;IACnD,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;AACzD,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type Token = "USDC";
|
|
2
|
+
export type CheckoutStatus = "pending" | "settling" | "paid" | "expired" | "failed";
|
|
3
|
+
export interface CreateCheckoutInput {
|
|
4
|
+
/** Amount in human units (e.g. 9.99 USDC). */
|
|
5
|
+
amount: number;
|
|
6
|
+
/** Opaque identifier the merchant uses to correlate the checkout
|
|
7
|
+
* back to their own order/cart. Max 200 chars. */
|
|
8
|
+
serviceId: string;
|
|
9
|
+
/** How long the checkout link stays valid. Default 24h, max 720h. */
|
|
10
|
+
expirationHours?: number;
|
|
11
|
+
/** HTTPS URL the gateway POSTs to when the payment lands. */
|
|
12
|
+
callbackUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Checkout {
|
|
15
|
+
id: string;
|
|
16
|
+
/** URL the customer is sent to. Hosts the private-pay flow. */
|
|
17
|
+
url: string;
|
|
18
|
+
amount: number;
|
|
19
|
+
token: Token;
|
|
20
|
+
serviceId: string;
|
|
21
|
+
status: CheckoutStatus;
|
|
22
|
+
expiresAt: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
callbackUrl: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface WebhookEvent {
|
|
27
|
+
requestId: string;
|
|
28
|
+
status: "paid";
|
|
29
|
+
merchantWallet: string;
|
|
30
|
+
payerWallet: string;
|
|
31
|
+
amount: number;
|
|
32
|
+
token: Token;
|
|
33
|
+
paidAt: string;
|
|
34
|
+
transactionSignature: string;
|
|
35
|
+
receiptSignatureHex: string;
|
|
36
|
+
gatewayPublicKey: string;
|
|
37
|
+
receiptVersion: "opaque.x402.receipt.v1";
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG,MAAM,CAAC;AAE3B,MAAM,MAAM,cAAc,GACtB,SAAS,GACT,UAAU,GACV,MAAM,GACN,SAAS,GACT,QAAQ,CAAC;AAEb,MAAM,WAAW,mBAAmB;IAClC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf;uDACmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,cAAc,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,oBAAoB,EAAE,MAAM,CAAC;IAC7B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,wBAAwB,CAAC;CAC1C"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opaqueprivacy/merchant-sdk",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Opaque Pay Merchant SDK — accept private crypto payments on your site.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./browser": {
|
|
15
|
+
"types": "./dist/browser.d.ts",
|
|
16
|
+
"import": "./dist/browser.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"bs58": "^5.0.0",
|
|
30
|
+
"tweetnacl": "^1.0.3"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.4.0",
|
|
34
|
+
"@types/node": "^20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/sorrowzzz/OpaquePrivacy.git",
|
|
42
|
+
"directory": "sdk"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"opaque",
|
|
46
|
+
"privacy",
|
|
47
|
+
"payments",
|
|
48
|
+
"solana",
|
|
49
|
+
"x402",
|
|
50
|
+
"zk"
|
|
51
|
+
]
|
|
52
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side helper for opening an Opaque Pay checkout as a popup and
|
|
3
|
+
* resolving when the payment lands. The popup posts a message back via
|
|
4
|
+
* window.opener.postMessage — see src/pages/Pay.tsx on the gateway.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { openCheckout } from "@opaqueprivacy/merchant-sdk/browser";
|
|
8
|
+
* const result = await openCheckout({ url: checkout.url });
|
|
9
|
+
* // result.transactionSignature is the on-chain proof of payment.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface OpenCheckoutOptions {
|
|
13
|
+
/** Checkout URL returned by OpaquePay.createCheckout(). */
|
|
14
|
+
url: string;
|
|
15
|
+
/** Popup width, default 480. */
|
|
16
|
+
width?: number;
|
|
17
|
+
/** Popup height, default 720. */
|
|
18
|
+
height?: number;
|
|
19
|
+
/** Abort if no payment within N ms. Default: no timeout. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CheckoutResult {
|
|
24
|
+
requestId: string;
|
|
25
|
+
transactionSignature: string | null;
|
|
26
|
+
amount: number;
|
|
27
|
+
token: "USDC";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class CheckoutCancelledError extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super("Checkout window was closed before payment completed");
|
|
33
|
+
this.name = "CheckoutCancelledError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class CheckoutTimeoutError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super("Checkout timed out");
|
|
40
|
+
this.name = "CheckoutTimeoutError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function openCheckout(
|
|
45
|
+
opts: OpenCheckoutOptions,
|
|
46
|
+
): Promise<CheckoutResult> {
|
|
47
|
+
const width = opts.width ?? 480;
|
|
48
|
+
const height = opts.height ?? 720;
|
|
49
|
+
const left = Math.max(0, Math.floor((window.screen.width - width) / 2));
|
|
50
|
+
const top = Math.max(0, Math.floor((window.screen.height - height) / 2));
|
|
51
|
+
const features = `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`;
|
|
52
|
+
|
|
53
|
+
const popup = window.open(opts.url, "opaque-pay-checkout", features);
|
|
54
|
+
if (!popup) {
|
|
55
|
+
return Promise.reject(
|
|
56
|
+
new Error(
|
|
57
|
+
"Popup blocked. Open the URL in a new tab or whitelist the domain.",
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise<CheckoutResult>((resolve, reject) => {
|
|
63
|
+
let done = false;
|
|
64
|
+
const cleanup = () => {
|
|
65
|
+
done = true;
|
|
66
|
+
window.removeEventListener("message", onMessage);
|
|
67
|
+
window.clearInterval(pollClosed);
|
|
68
|
+
if (timeoutHandle) window.clearTimeout(timeoutHandle);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const onMessage = (ev: MessageEvent) => {
|
|
72
|
+
const data = ev.data as
|
|
73
|
+
| {
|
|
74
|
+
type?: string;
|
|
75
|
+
requestId?: string;
|
|
76
|
+
transactionSignature?: string | null;
|
|
77
|
+
amount?: number;
|
|
78
|
+
token?: "USDC";
|
|
79
|
+
}
|
|
80
|
+
| null;
|
|
81
|
+
if (!data || data.type !== "opaque-pay-success") return;
|
|
82
|
+
if (!data.requestId) return;
|
|
83
|
+
cleanup();
|
|
84
|
+
try {
|
|
85
|
+
popup.close();
|
|
86
|
+
} catch {
|
|
87
|
+
// popup may already be closed cross-origin; ignore.
|
|
88
|
+
}
|
|
89
|
+
resolve({
|
|
90
|
+
requestId: data.requestId,
|
|
91
|
+
transactionSignature: data.transactionSignature ?? null,
|
|
92
|
+
amount: data.amount ?? 0,
|
|
93
|
+
token: data.token ?? "USDC",
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener("message", onMessage);
|
|
97
|
+
|
|
98
|
+
const pollClosed = window.setInterval(() => {
|
|
99
|
+
if (done) return;
|
|
100
|
+
if (popup.closed) {
|
|
101
|
+
cleanup();
|
|
102
|
+
reject(new CheckoutCancelledError());
|
|
103
|
+
}
|
|
104
|
+
}, 500);
|
|
105
|
+
|
|
106
|
+
const timeoutHandle = opts.timeoutMs
|
|
107
|
+
? window.setTimeout(() => {
|
|
108
|
+
if (done) return;
|
|
109
|
+
cleanup();
|
|
110
|
+
try {
|
|
111
|
+
popup.close();
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
reject(new CheckoutTimeoutError());
|
|
116
|
+
}, opts.timeoutMs)
|
|
117
|
+
: null;
|
|
118
|
+
});
|
|
119
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Checkout,
|
|
3
|
+
CreateCheckoutInput,
|
|
4
|
+
WebhookEvent,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
import { verifyReceipt } from "./receipt.js";
|
|
7
|
+
|
|
8
|
+
export interface OpaquePayOptions {
|
|
9
|
+
/** API key generated in the Opaque dashboard (op_live_...). */
|
|
10
|
+
apiKey: string;
|
|
11
|
+
/** Base URL of the Opaque gateway. Defaults to production. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Optional fetch implementation (e.g. for testing or non-global fetch). */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OpaquePayError extends Error {
|
|
18
|
+
status: number;
|
|
19
|
+
body: unknown;
|
|
20
|
+
constructor(status: number, message: string, body: unknown) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "OpaquePayError";
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.body = body;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class OpaquePay {
|
|
29
|
+
private apiKey: string;
|
|
30
|
+
private baseUrl: string;
|
|
31
|
+
private fetchImpl: typeof fetch;
|
|
32
|
+
|
|
33
|
+
constructor(opts: OpaquePayOptions) {
|
|
34
|
+
if (!opts.apiKey) {
|
|
35
|
+
throw new Error("OpaquePay: apiKey is required");
|
|
36
|
+
}
|
|
37
|
+
if (!opts.apiKey.startsWith("op_live_")) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"OpaquePay: apiKey must start with op_live_ (generate one in your dashboard)",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
this.apiKey = opts.apiKey;
|
|
43
|
+
this.baseUrl = (opts.baseUrl ?? "https://opaque.privacy").replace(/\/$/, "");
|
|
44
|
+
this.fetchImpl = opts.fetch ?? (globalThis.fetch as typeof fetch);
|
|
45
|
+
if (!this.fetchImpl) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"OpaquePay: no fetch implementation found. Pass one via options.fetch.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a private checkout. Send the customer to `checkout.url` —
|
|
54
|
+
* either by redirecting, or by passing the URL to the browser widget.
|
|
55
|
+
*/
|
|
56
|
+
async createCheckout(input: CreateCheckoutInput): Promise<Checkout> {
|
|
57
|
+
if (!(input.amount > 0)) {
|
|
58
|
+
throw new Error("createCheckout: amount must be > 0");
|
|
59
|
+
}
|
|
60
|
+
if (!input.serviceId?.trim()) {
|
|
61
|
+
throw new Error("createCheckout: serviceId is required");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/x402/create`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
amount: input.amount,
|
|
72
|
+
serviceId: input.serviceId,
|
|
73
|
+
expirationHours: input.expirationHours,
|
|
74
|
+
callbackUrl: input.callbackUrl,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const json = await safeJson(res);
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
throw new OpaquePayError(
|
|
81
|
+
res.status,
|
|
82
|
+
(json as any)?.error ?? `Failed to create checkout (${res.status})`,
|
|
83
|
+
json,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: (json as any).id,
|
|
89
|
+
url: (json as any).url,
|
|
90
|
+
amount: (json as any).amount,
|
|
91
|
+
token: (json as any).token,
|
|
92
|
+
serviceId: (json as any).serviceId,
|
|
93
|
+
status: (json as any).status,
|
|
94
|
+
expiresAt: (json as any).expiresAt,
|
|
95
|
+
createdAt: (json as any).createdAt,
|
|
96
|
+
callbackUrl: (json as any).callbackUrl ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fetch the latest state of a checkout (status, payer, tx sig). */
|
|
101
|
+
async retrieveCheckout(id: string): Promise<Checkout & {
|
|
102
|
+
payerWallet?: string;
|
|
103
|
+
transactionSignature?: string;
|
|
104
|
+
paidAt?: string;
|
|
105
|
+
}> {
|
|
106
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/x402/${id}`, {
|
|
107
|
+
method: "GET",
|
|
108
|
+
});
|
|
109
|
+
const json = await safeJson(res);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new OpaquePayError(
|
|
112
|
+
res.status,
|
|
113
|
+
(json as any)?.error ?? `Failed to retrieve checkout (${res.status})`,
|
|
114
|
+
json,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return json as any;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Verify a webhook payload using the receipt signature the gateway
|
|
122
|
+
* sends alongside it. Returns the parsed event if valid, throws if
|
|
123
|
+
* the signature doesn't verify.
|
|
124
|
+
*
|
|
125
|
+
* Pass the raw request body (string) and the signature from the
|
|
126
|
+
* X-Opaque-Receipt-Signature header.
|
|
127
|
+
*/
|
|
128
|
+
verifyWebhook(rawBody: string, signatureHexHeader: string | undefined): WebhookEvent {
|
|
129
|
+
if (!signatureHexHeader) {
|
|
130
|
+
throw new Error("verifyWebhook: missing X-Opaque-Receipt-Signature header");
|
|
131
|
+
}
|
|
132
|
+
let parsed: WebhookEvent;
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(rawBody) as WebhookEvent;
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error("verifyWebhook: body is not valid JSON");
|
|
137
|
+
}
|
|
138
|
+
if (parsed.receiptSignatureHex !== signatureHexHeader) {
|
|
139
|
+
throw new Error("verifyWebhook: header signature does not match body");
|
|
140
|
+
}
|
|
141
|
+
if (!verifyReceipt(parsed)) {
|
|
142
|
+
throw new Error("verifyWebhook: signature is invalid");
|
|
143
|
+
}
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function safeJson(res: Response): Promise<unknown> {
|
|
149
|
+
try {
|
|
150
|
+
return await res.json();
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { OpaquePay, OpaquePayError } from "./client.js";
|
|
2
|
+
export type { OpaquePayOptions } from "./client.js";
|
|
3
|
+
export {
|
|
4
|
+
buildCanonicalReceipt,
|
|
5
|
+
verifyReceipt,
|
|
6
|
+
} from "./receipt.js";
|
|
7
|
+
export type {
|
|
8
|
+
Checkout,
|
|
9
|
+
CheckoutStatus,
|
|
10
|
+
CreateCheckoutInput,
|
|
11
|
+
Token,
|
|
12
|
+
WebhookEvent,
|
|
13
|
+
} from "./types.js";
|
package/src/receipt.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify the ed25519 receipt the Opaque gateway signs for every paid
|
|
3
|
+
* checkout. The canonical message must match lib/x402-receipt.ts on the
|
|
4
|
+
* gateway side — if you change one, change both.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import nacl from "tweetnacl";
|
|
8
|
+
import bs58 from "bs58";
|
|
9
|
+
import type { WebhookEvent } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const RECEIPT_VERSION = "opaque.x402.receipt.v1";
|
|
12
|
+
|
|
13
|
+
function formatAmount(n: number): string {
|
|
14
|
+
return Number(n.toFixed(6)).toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildCanonicalReceipt(e: {
|
|
18
|
+
requestId: string;
|
|
19
|
+
merchantWallet: string;
|
|
20
|
+
payerWallet: string;
|
|
21
|
+
amount: number;
|
|
22
|
+
token: "USDC";
|
|
23
|
+
paidAt: Date;
|
|
24
|
+
transactionSignature: string;
|
|
25
|
+
}): string {
|
|
26
|
+
return [
|
|
27
|
+
RECEIPT_VERSION,
|
|
28
|
+
e.requestId,
|
|
29
|
+
e.merchantWallet,
|
|
30
|
+
e.payerWallet,
|
|
31
|
+
formatAmount(e.amount),
|
|
32
|
+
e.token,
|
|
33
|
+
Math.floor(e.paidAt.getTime() / 1000).toString(),
|
|
34
|
+
e.transactionSignature,
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
39
|
+
if (hex.length % 2 !== 0) throw new Error("invalid hex");
|
|
40
|
+
const out = new Uint8Array(hex.length / 2);
|
|
41
|
+
for (let i = 0; i < out.length; i++) {
|
|
42
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function verifyReceipt(event: WebhookEvent): boolean {
|
|
48
|
+
const message = new TextEncoder().encode(
|
|
49
|
+
buildCanonicalReceipt({
|
|
50
|
+
requestId: event.requestId,
|
|
51
|
+
merchantWallet: event.merchantWallet,
|
|
52
|
+
payerWallet: event.payerWallet,
|
|
53
|
+
amount: event.amount,
|
|
54
|
+
token: event.token,
|
|
55
|
+
paidAt: new Date(event.paidAt),
|
|
56
|
+
transactionSignature: event.transactionSignature,
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
const sig = hexToBytes(event.receiptSignatureHex);
|
|
60
|
+
const pubkey = bs58.decode(event.gatewayPublicKey);
|
|
61
|
+
return nacl.sign.detached.verify(message, sig, pubkey);
|
|
62
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type Token = "USDC";
|
|
2
|
+
|
|
3
|
+
export type CheckoutStatus =
|
|
4
|
+
| "pending"
|
|
5
|
+
| "settling"
|
|
6
|
+
| "paid"
|
|
7
|
+
| "expired"
|
|
8
|
+
| "failed";
|
|
9
|
+
|
|
10
|
+
export interface CreateCheckoutInput {
|
|
11
|
+
/** Amount in human units (e.g. 9.99 USDC). */
|
|
12
|
+
amount: number;
|
|
13
|
+
/** Opaque identifier the merchant uses to correlate the checkout
|
|
14
|
+
* back to their own order/cart. Max 200 chars. */
|
|
15
|
+
serviceId: string;
|
|
16
|
+
/** How long the checkout link stays valid. Default 24h, max 720h. */
|
|
17
|
+
expirationHours?: number;
|
|
18
|
+
/** HTTPS URL the gateway POSTs to when the payment lands. */
|
|
19
|
+
callbackUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Checkout {
|
|
23
|
+
id: string;
|
|
24
|
+
/** URL the customer is sent to. Hosts the private-pay flow. */
|
|
25
|
+
url: string;
|
|
26
|
+
amount: number;
|
|
27
|
+
token: Token;
|
|
28
|
+
serviceId: string;
|
|
29
|
+
status: CheckoutStatus;
|
|
30
|
+
expiresAt: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
callbackUrl: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WebhookEvent {
|
|
36
|
+
requestId: string;
|
|
37
|
+
status: "paid";
|
|
38
|
+
merchantWallet: string;
|
|
39
|
+
payerWallet: string;
|
|
40
|
+
amount: number;
|
|
41
|
+
token: Token;
|
|
42
|
+
paidAt: string;
|
|
43
|
+
transactionSignature: string;
|
|
44
|
+
receiptSignatureHex: string;
|
|
45
|
+
gatewayPublicKey: string;
|
|
46
|
+
receiptVersion: "opaque.x402.receipt.v1";
|
|
47
|
+
}
|