@finrock/mcp 0.0.0-stage → 1.0.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 +7 -0
- package/README.md +214 -2
- package/dist/api/client.d.ts +50 -0
- package/dist/api/client.js +172 -0
- package/dist/api/tools.d.ts +17 -0
- package/dist/api/tools.js +300 -0
- package/dist/docs.d.ts +61 -0
- package/dist/docs.js +190 -0
- package/dist/guides.d.ts +19 -0
- package/dist/guides.js +66 -0
- package/dist/http.d.ts +13 -0
- package/dist/http.js +94 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +229 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +123 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +28 -0
- package/dist/webhook.d.ts +11 -0
- package/dist/webhook.js +39 -0
- package/guides/_client/nextjs.md +57 -0
- package/guides/_client/node.md +55 -0
- package/guides/_client/php.md +67 -0
- package/guides/_client/python.md +55 -0
- package/guides/accept-payments/nextjs.md +75 -0
- package/guides/accept-payments/node.md +49 -0
- package/guides/accept-payments/overview.md +30 -0
- package/guides/accept-payments/php.md +61 -0
- package/guides/accept-payments/python.md +59 -0
- package/guides/authentication/overview.md +60 -0
- package/guides/deposit-addresses/nextjs.md +50 -0
- package/guides/deposit-addresses/node.md +30 -0
- package/guides/deposit-addresses/overview.md +36 -0
- package/guides/deposit-addresses/php.md +35 -0
- package/guides/deposit-addresses/python.md +31 -0
- package/guides/payment-widget/html.md +26 -0
- package/guides/payment-widget/nextjs.md +41 -0
- package/guides/payment-widget/overview.md +32 -0
- package/guides/webhooks/nextjs.md +48 -0
- package/guides/webhooks/node.md +48 -0
- package/guides/webhooks/overview.md +34 -0
- package/guides/webhooks/php.md +46 -0
- package/guides/webhooks/python.md +55 -0
- package/guides/withdrawals/nextjs.md +35 -0
- package/guides/withdrawals/node.md +35 -0
- package/guides/withdrawals/overview.md +32 -0
- package/guides/withdrawals/php.md +37 -0
- package/guides/withdrawals/python.md +33 -0
- package/package.json +55 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
### Finrock API client (`finrock.ts`)
|
|
2
|
+
|
|
3
|
+
No SDK needed: this signs each request with Node's built-in `crypto`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// finrock.ts — server-side only. Never import this into browser code.
|
|
7
|
+
import { createHash, createSign, randomUUID } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const API_BASE = "https://api.finrock.io";
|
|
10
|
+
const API_KEY = process.env.FINROCK_API_KEY!; // API Key UUID from Finrock Dashboard → API Users
|
|
11
|
+
// PEM of the RSA-4096 private key whose public half you uploaded. Env vars often
|
|
12
|
+
// store newlines as "\n", so restore them.
|
|
13
|
+
const PRIVATE_KEY = process.env.FINROCK_PRIVATE_KEY!.replace(/\\n/g, "\n");
|
|
14
|
+
|
|
15
|
+
const b64url = (b: Buffer | string) => Buffer.from(b).toString("base64url");
|
|
16
|
+
|
|
17
|
+
/** A fresh RS256 JWT for one request. Finrock rejects reused nonces and exp > iat + 30s. */
|
|
18
|
+
function signJwt(uri: string, rawBody: string): string {
|
|
19
|
+
const now = Math.floor(Date.now() / 1000);
|
|
20
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
21
|
+
const payload = {
|
|
22
|
+
uri, // request path, e.g. /dac/v1/invoice
|
|
23
|
+
nonce: randomUUID(),
|
|
24
|
+
iat: now,
|
|
25
|
+
exp: now + 25,
|
|
26
|
+
sub: API_KEY,
|
|
27
|
+
bodyHash: createHash("sha256").update(rawBody).digest("hex"),
|
|
28
|
+
};
|
|
29
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
|
30
|
+
const signature = createSign("RSA-SHA256").update(input).sign(PRIVATE_KEY);
|
|
31
|
+
return `${input}.${b64url(signature)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function finrock<T = any>(
|
|
35
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
36
|
+
path: string,
|
|
37
|
+
body?: unknown,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
// Hash exactly the string you send: serialise once and reuse it.
|
|
40
|
+
const rawBody = body === undefined ? "" : JSON.stringify(body);
|
|
41
|
+
const res = await fetch(API_BASE + path, {
|
|
42
|
+
method,
|
|
43
|
+
headers: {
|
|
44
|
+
authorization: `Bearer ${signJwt(path, rawBody)}`,
|
|
45
|
+
"x-api-key": API_KEY,
|
|
46
|
+
accept: "application/json",
|
|
47
|
+
...(rawBody ? { "content-type": "application/json" } : {}),
|
|
48
|
+
},
|
|
49
|
+
body: rawBody || undefined,
|
|
50
|
+
});
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
if (!res.ok) throw new Error(`Finrock ${method} ${path} -> HTTP ${res.status}: ${text}`);
|
|
53
|
+
return (text ? JSON.parse(text) : null) as T;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
### Finrock API client (`finrock.php`)
|
|
2
|
+
|
|
3
|
+
Uses only the `openssl`, `curl` and `json` extensions.
|
|
4
|
+
|
|
5
|
+
```php
|
|
6
|
+
<?php
|
|
7
|
+
// finrock.php — server-side only.
|
|
8
|
+
const FINROCK_API_BASE = 'https://api.finrock.io';
|
|
9
|
+
|
|
10
|
+
function finrock_b64url(string $s): string
|
|
11
|
+
{
|
|
12
|
+
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A fresh RS256 JWT for one request. Finrock rejects reused nonces and exp > iat + 30s. */
|
|
16
|
+
function finrock_sign(string $uri, string $rawBody): string
|
|
17
|
+
{
|
|
18
|
+
$now = time();
|
|
19
|
+
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
|
|
20
|
+
$payload = [
|
|
21
|
+
'uri' => $uri, // request path, e.g. /dac/v1/invoice
|
|
22
|
+
'nonce' => bin2hex(random_bytes(16)),
|
|
23
|
+
'iat' => $now,
|
|
24
|
+
'exp' => $now + 25,
|
|
25
|
+
'sub' => getenv('FINROCK_API_KEY'), // API Key UUID from Finrock Dashboard -> API Users
|
|
26
|
+
'bodyHash' => hash('sha256', $rawBody),
|
|
27
|
+
];
|
|
28
|
+
$input = finrock_b64url(json_encode($header)) . '.' . finrock_b64url(json_encode($payload, JSON_UNESCAPED_SLASHES));
|
|
29
|
+
// PEM of the RSA-4096 private key whose public half you uploaded.
|
|
30
|
+
$key = openssl_pkey_get_private(str_replace('\n', "\n", getenv('FINROCK_PRIVATE_KEY')));
|
|
31
|
+
if ($key === false || !openssl_sign($input, $signature, $key, OPENSSL_ALGO_SHA256)) {
|
|
32
|
+
throw new RuntimeException('Could not sign the Finrock JWT: check FINROCK_PRIVATE_KEY');
|
|
33
|
+
}
|
|
34
|
+
return $input . '.' . finrock_b64url($signature);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function finrock(string $method, string $path, ?array $body = null)
|
|
38
|
+
{
|
|
39
|
+
// Hash exactly the string you send: serialise once and reuse it.
|
|
40
|
+
$rawBody = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
|
|
41
|
+
$headers = [
|
|
42
|
+
'Authorization: Bearer ' . finrock_sign($path, $rawBody),
|
|
43
|
+
'x-api-key: ' . getenv('FINROCK_API_KEY'),
|
|
44
|
+
'Accept: application/json',
|
|
45
|
+
];
|
|
46
|
+
if ($rawBody !== '') {
|
|
47
|
+
$headers[] = 'Content-Type: application/json';
|
|
48
|
+
}
|
|
49
|
+
$ch = curl_init(FINROCK_API_BASE . $path);
|
|
50
|
+
curl_setopt_array($ch, [
|
|
51
|
+
CURLOPT_CUSTOMREQUEST => $method,
|
|
52
|
+
CURLOPT_HTTPHEADER => $headers,
|
|
53
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
54
|
+
CURLOPT_TIMEOUT => 30,
|
|
55
|
+
]);
|
|
56
|
+
if ($rawBody !== '') {
|
|
57
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, $rawBody);
|
|
58
|
+
}
|
|
59
|
+
$response = curl_exec($ch);
|
|
60
|
+
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
61
|
+
curl_close($ch);
|
|
62
|
+
if ($response === false || $status >= 400) {
|
|
63
|
+
throw new RuntimeException("Finrock $method $path -> HTTP $status: $response");
|
|
64
|
+
}
|
|
65
|
+
return $response === '' ? null : json_decode($response, true);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
### Finrock API client (`finrock.py`)
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install "pyjwt[crypto]" requests
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# finrock.py — server-side only.
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
import jwt # PyJWT
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
API_BASE = "https://api.finrock.io"
|
|
19
|
+
API_KEY = os.environ["FINROCK_API_KEY"] # API Key UUID from Finrock Dashboard -> API Users
|
|
20
|
+
# PEM of the RSA-4096 private key whose public half you uploaded.
|
|
21
|
+
PRIVATE_KEY = os.environ["FINROCK_PRIVATE_KEY"].replace("\\n", "\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sign(uri: str, raw_body: str) -> str:
|
|
25
|
+
"""A fresh RS256 JWT for one request. Finrock rejects reused nonces and exp > iat + 30s."""
|
|
26
|
+
now = int(time.time())
|
|
27
|
+
payload = {
|
|
28
|
+
"uri": uri, # request path, e.g. /dac/v1/invoice
|
|
29
|
+
"nonce": str(uuid.uuid4()),
|
|
30
|
+
"iat": now,
|
|
31
|
+
"exp": now + 25,
|
|
32
|
+
"sub": API_KEY,
|
|
33
|
+
"bodyHash": hashlib.sha256(raw_body.encode("utf-8")).hexdigest(),
|
|
34
|
+
}
|
|
35
|
+
return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def finrock(method: str, path: str, body=None):
|
|
39
|
+
# Hash exactly the bytes you send: serialise once and reuse them.
|
|
40
|
+
raw_body = "" if body is None else json.dumps(body, separators=(",", ":"))
|
|
41
|
+
headers = {
|
|
42
|
+
"Authorization": f"Bearer {_sign(path, raw_body)}",
|
|
43
|
+
"x-api-key": API_KEY,
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
}
|
|
46
|
+
if raw_body:
|
|
47
|
+
headers["Content-Type"] = "application/json"
|
|
48
|
+
res = requests.request(
|
|
49
|
+
method, API_BASE + path, data=raw_body.encode("utf-8") if raw_body else None,
|
|
50
|
+
headers=headers, timeout=30,
|
|
51
|
+
)
|
|
52
|
+
if not res.ok:
|
|
53
|
+
raise RuntimeError(f"Finrock {method} {path} -> HTTP {res.status_code}: {res.text}")
|
|
54
|
+
return res.json() if res.text else None
|
|
55
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
### Server Action to start checkout (`app/checkout/actions.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
"use server";
|
|
5
|
+
import { redirect } from "next/navigation";
|
|
6
|
+
import { finrock } from "@/lib/finrock";
|
|
7
|
+
import { db } from "@/lib/db"; // your data layer
|
|
8
|
+
|
|
9
|
+
const BASE_URL = process.env.PUBLIC_BASE_URL!; // e.g. https://shop.example.com
|
|
10
|
+
|
|
11
|
+
export async function payWithCrypto(orderId: string) {
|
|
12
|
+
const order = await db.orders.get(orderId);
|
|
13
|
+
const invoice = await finrock("POST", "/dac/v1/invoice", {
|
|
14
|
+
fiat: { asset_id: order.currency, amount: order.total }, // e.g. "USD", 49.99
|
|
15
|
+
crypto: { asset_id: "USDT_TRX" }, // the coin to collect; Finrock converts the fiat amount
|
|
16
|
+
memo: `order-${order.id}`,
|
|
17
|
+
payer: { email: order.email },
|
|
18
|
+
ipn_url: `${BASE_URL}/api/finrock/ipn?order=${order.id}`,
|
|
19
|
+
success_url: `${BASE_URL}/orders/${order.id}/thanks`,
|
|
20
|
+
failed_url: `${BASE_URL}/orders/${order.id}/payment-failed`,
|
|
21
|
+
});
|
|
22
|
+
await db.orders.update(order.id, { finrockInvoiceId: invoice.id, paymentStatus: invoice.status });
|
|
23
|
+
redirect(invoice.payment_url);
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Shared status check (`lib/sync-invoice.ts`)
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import "server-only";
|
|
31
|
+
import { finrock } from "@/lib/finrock";
|
|
32
|
+
import { db } from "@/lib/db";
|
|
33
|
+
|
|
34
|
+
// Re-check the invoice with Finrock instead of trusting the notification or redirect.
|
|
35
|
+
export async function syncInvoice(orderId: string) {
|
|
36
|
+
const order = await db.orders.get(orderId);
|
|
37
|
+
if (!order?.finrockInvoiceId) return order;
|
|
38
|
+
const [invoice] = await finrock("GET", `/dac/v1/invoice?id=${encodeURIComponent(order.finrockInvoiceId)}`);
|
|
39
|
+
if (invoice?.status === "Paid" && order.paymentStatus !== "Paid") {
|
|
40
|
+
await db.orders.update(orderId, { paymentStatus: "Paid" });
|
|
41
|
+
await fulfil(orderId); // must be safe to call twice
|
|
42
|
+
} else if (invoice && invoice.status !== order.paymentStatus) {
|
|
43
|
+
await db.orders.update(orderId, { paymentStatus: invoice.status });
|
|
44
|
+
}
|
|
45
|
+
return db.orders.get(orderId);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Notification endpoint (`app/api/finrock/ipn/route.ts`)
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { syncInvoice } from "@/lib/sync-invoice";
|
|
53
|
+
|
|
54
|
+
export const runtime = "nodejs";
|
|
55
|
+
|
|
56
|
+
export async function POST(req: Request) {
|
|
57
|
+
const orderId = new URL(req.url).searchParams.get("order");
|
|
58
|
+
if (orderId) await syncInvoice(orderId);
|
|
59
|
+
return new Response("ok", { status: 200 });
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Thank-you page (`app/orders/[id]/thanks/page.tsx`)
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { syncInvoice } from "@/lib/sync-invoice";
|
|
67
|
+
|
|
68
|
+
export default async function Thanks({ params }: { params: Promise<{ id: string }> }) {
|
|
69
|
+
const { id } = await params;
|
|
70
|
+
const order = await syncInvoice(id);
|
|
71
|
+
return order.paymentStatus === "Paid"
|
|
72
|
+
? <p>Payment received. Thank you!</p>
|
|
73
|
+
: <p>We are waiting for your payment to confirm on-chain ({order.paymentStatus}).</p>;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
### Express routes (`server.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { finrock } from "./finrock";
|
|
6
|
+
|
|
7
|
+
const app = express();
|
|
8
|
+
app.use(express.json());
|
|
9
|
+
const BASE_URL = process.env.PUBLIC_BASE_URL!; // e.g. https://shop.example.com
|
|
10
|
+
|
|
11
|
+
app.post("/checkout/:orderId/crypto", async (req, res) => {
|
|
12
|
+
const order = await db.orders.get(req.params.orderId); // your data layer
|
|
13
|
+
const invoice = await finrock("POST", "/dac/v1/invoice", {
|
|
14
|
+
fiat: { asset_id: order.currency, amount: order.total }, // e.g. "USD", 49.99
|
|
15
|
+
crypto: { asset_id: "USDT_TRX" }, // the coin to collect; Finrock converts the fiat amount
|
|
16
|
+
memo: `order-${order.id}`,
|
|
17
|
+
payer: { email: order.email },
|
|
18
|
+
ipn_url: `${BASE_URL}/finrock/ipn?order=${order.id}`,
|
|
19
|
+
success_url: `${BASE_URL}/orders/${order.id}/thanks`,
|
|
20
|
+
failed_url: `${BASE_URL}/orders/${order.id}/payment-failed`,
|
|
21
|
+
});
|
|
22
|
+
await db.orders.update(order.id, { finrockInvoiceId: invoice.id, paymentStatus: invoice.status });
|
|
23
|
+
res.redirect(303, invoice.payment_url);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Re-check the invoice with Finrock instead of trusting the notification body.
|
|
27
|
+
async function syncInvoice(orderId: string) {
|
|
28
|
+
const order = await db.orders.get(orderId);
|
|
29
|
+
if (!order?.finrockInvoiceId) return;
|
|
30
|
+
const [invoice] = await finrock("GET", `/dac/v1/invoice?id=${encodeURIComponent(order.finrockInvoiceId)}`);
|
|
31
|
+
if (!invoice) return;
|
|
32
|
+
if (invoice.status === "Paid" && order.paymentStatus !== "Paid") {
|
|
33
|
+
await db.orders.update(orderId, { paymentStatus: "Paid" });
|
|
34
|
+
await fulfil(orderId); // must be safe to call twice
|
|
35
|
+
} else if (invoice.status !== order.paymentStatus) {
|
|
36
|
+
await db.orders.update(orderId, { paymentStatus: invoice.status });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
app.post("/finrock/ipn", async (req, res) => {
|
|
41
|
+
await syncInvoice(String(req.query.order));
|
|
42
|
+
res.sendStatus(200);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get("/orders/:orderId/thanks", async (req, res) => {
|
|
46
|
+
await syncInvoice(req.params.orderId);
|
|
47
|
+
res.render("thanks", { order: await db.orders.get(req.params.orderId) });
|
|
48
|
+
});
|
|
49
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Accept a crypto payment with a hosted invoice
|
|
3
|
+
summary: Create an invoice on your server, send the customer to Finrock's hosted payment page, and mark the order paid after re-checking the invoice.
|
|
4
|
+
docs: api-reference/invoicing/create-invoice, api-reference/invoicing/list-invoices, reference/supported-assets
|
|
5
|
+
client: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This is the simplest way to take crypto payments for an order, subscription or invoice.
|
|
9
|
+
|
|
10
|
+
### Flow
|
|
11
|
+
|
|
12
|
+
1. **Customer checks out.** Your server calls `POST /dac/v1/invoice` with the price and a
|
|
13
|
+
unique `memo` (your order ID works well).
|
|
14
|
+
- `crypto.asset_id` is always required: the coin to collect, e.g. `crypto: { asset_id: "USDT_TRX" }`
|
|
15
|
+
(asset IDs come from Supported Assets). Without it the API returns `ASSET_REQUIRED`.
|
|
16
|
+
- Price in fiat with `fiat: { asset_id: "USD", amount }` and Finrock converts it to the coin, or
|
|
17
|
+
- give `crypto.amount` to price in the coin directly.
|
|
18
|
+
- Set `success_url`, `failed_url`, and `ipn_url` (payment notifications).
|
|
19
|
+
2. **Save** the returned invoice `id` against your order, then **redirect** the customer to
|
|
20
|
+
the returned `payment_url`.
|
|
21
|
+
3. **Confirm payment on your server.** When the `ipn_url` is called, or the customer lands on
|
|
22
|
+
`success_url`, fetch the invoice with `GET /dac/v1/invoice?id=<invoice id>` and fulfil only
|
|
23
|
+
when its `status` is `Paid`. Treat the redirect and notification as hints, never as proof.
|
|
24
|
+
4. **Make fulfilment idempotent.** Notifications and redirects can arrive more than once.
|
|
25
|
+
|
|
26
|
+
Invoice statuses: `Pending`, `Processing`, `Paid`, `Expired`, `Failed`, `Cancelled`, `Refunded`.
|
|
27
|
+
Invoices expire at `expiry_ts`; late payments may need manual reconciliation.
|
|
28
|
+
|
|
29
|
+
Put your own order ID in the `ipn_url` query string (as below) so the notification handler can
|
|
30
|
+
find the order without depending on the notification body, whose format is not documented yet.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
### Start checkout (`checkout_crypto.php`)
|
|
2
|
+
|
|
3
|
+
```php
|
|
4
|
+
<?php
|
|
5
|
+
require __DIR__ . '/finrock.php';
|
|
6
|
+
require __DIR__ . '/db.php'; // your data layer
|
|
7
|
+
|
|
8
|
+
$baseUrl = getenv('PUBLIC_BASE_URL'); // e.g. https://shop.example.com
|
|
9
|
+
$order = db_get_order($_POST['order_id']);
|
|
10
|
+
|
|
11
|
+
$invoice = finrock('POST', '/dac/v1/invoice', [
|
|
12
|
+
'fiat' => ['asset_id' => $order['currency'], 'amount' => (float) $order['total']], // e.g. "USD", 49.99
|
|
13
|
+
'crypto' => ['asset_id' => 'USDT_TRX'], // the coin to collect; Finrock converts the fiat amount
|
|
14
|
+
'memo' => 'order-' . $order['id'],
|
|
15
|
+
'payer' => ['email' => $order['email']],
|
|
16
|
+
'ipn_url' => "$baseUrl/finrock_ipn.php?order=" . urlencode($order['id']),
|
|
17
|
+
'success_url' => "$baseUrl/thanks.php?order=" . urlencode($order['id']),
|
|
18
|
+
'failed_url' => "$baseUrl/payment_failed.php?order=" . urlencode($order['id']),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
db_update_order($order['id'], ['finrock_invoice_id' => $invoice['id'], 'payment_status' => $invoice['status']]);
|
|
22
|
+
header('Location: ' . $invoice['payment_url'], true, 303);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Shared status check (`sync_invoice.php`)
|
|
26
|
+
|
|
27
|
+
```php
|
|
28
|
+
<?php
|
|
29
|
+
require_once __DIR__ . '/finrock.php';
|
|
30
|
+
require_once __DIR__ . '/db.php';
|
|
31
|
+
|
|
32
|
+
// Re-check the invoice with Finrock instead of trusting the notification body.
|
|
33
|
+
function sync_invoice(string $orderId): ?array
|
|
34
|
+
{
|
|
35
|
+
$order = db_get_order($orderId);
|
|
36
|
+
if (!$order || empty($order['finrock_invoice_id'])) {
|
|
37
|
+
return $order;
|
|
38
|
+
}
|
|
39
|
+
$invoices = finrock('GET', '/dac/v1/invoice?id=' . rawurlencode($order['finrock_invoice_id'])) ?? [];
|
|
40
|
+
if ($invoices) {
|
|
41
|
+
$status = $invoices[0]['status'];
|
|
42
|
+
if ($status === 'Paid' && $order['payment_status'] !== 'Paid') {
|
|
43
|
+
db_update_order($orderId, ['payment_status' => 'Paid']);
|
|
44
|
+
fulfil($orderId); // must be safe to call twice
|
|
45
|
+
} elseif ($status !== $order['payment_status']) {
|
|
46
|
+
db_update_order($orderId, ['payment_status' => $status]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return db_get_order($orderId);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Notification endpoint (`finrock_ipn.php`)
|
|
54
|
+
|
|
55
|
+
```php
|
|
56
|
+
<?php
|
|
57
|
+
require __DIR__ . '/sync_invoice.php';
|
|
58
|
+
sync_invoice($_GET['order'] ?? '');
|
|
59
|
+
http_response_code(200);
|
|
60
|
+
echo 'ok';
|
|
61
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
### FastAPI routes (`main.py`)
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
|
|
10
|
+
from finrock import finrock
|
|
11
|
+
from mydb import db, fulfil # your data layer
|
|
12
|
+
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
BASE_URL = os.environ["PUBLIC_BASE_URL"] # e.g. https://shop.example.com
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.post("/checkout/{order_id}/crypto")
|
|
18
|
+
def start_crypto_checkout(order_id: str):
|
|
19
|
+
order = db.get_order(order_id)
|
|
20
|
+
invoice = finrock("POST", "/dac/v1/invoice", {
|
|
21
|
+
"fiat": {"asset_id": order.currency, "amount": float(order.total)}, # e.g. "USD", 49.99
|
|
22
|
+
"crypto": {"asset_id": "USDT_TRX"}, # the coin to collect; Finrock converts the fiat amount
|
|
23
|
+
"memo": f"order-{order.id}",
|
|
24
|
+
"payer": {"email": order.email},
|
|
25
|
+
"ipn_url": f"{BASE_URL}/finrock/ipn?order={order.id}",
|
|
26
|
+
"success_url": f"{BASE_URL}/orders/{order.id}/thanks",
|
|
27
|
+
"failed_url": f"{BASE_URL}/orders/{order.id}/payment-failed",
|
|
28
|
+
})
|
|
29
|
+
db.update_order(order.id, finrock_invoice_id=invoice["id"], payment_status=invoice["status"])
|
|
30
|
+
return RedirectResponse(invoice["payment_url"], status_code=303)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sync_invoice(order_id: str):
|
|
34
|
+
"""Re-check the invoice with Finrock instead of trusting the notification body."""
|
|
35
|
+
order = db.get_order(order_id)
|
|
36
|
+
if not order or not order.finrock_invoice_id:
|
|
37
|
+
return order
|
|
38
|
+
invoices = finrock("GET", f"/dac/v1/invoice?id={quote(order.finrock_invoice_id)}") or []
|
|
39
|
+
if invoices:
|
|
40
|
+
status = invoices[0]["status"]
|
|
41
|
+
if status == "Paid" and order.payment_status != "Paid":
|
|
42
|
+
db.update_order(order_id, payment_status="Paid")
|
|
43
|
+
fulfil(order_id) # must be safe to call twice
|
|
44
|
+
elif status != order.payment_status:
|
|
45
|
+
db.update_order(order_id, payment_status=status)
|
|
46
|
+
return db.get_order(order_id)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.post("/finrock/ipn")
|
|
50
|
+
def finrock_ipn(order: str):
|
|
51
|
+
sync_invoice(order)
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.get("/orders/{order_id}/thanks")
|
|
56
|
+
def thanks(order_id: str):
|
|
57
|
+
order = sync_invoice(order_id)
|
|
58
|
+
return {"status": order.payment_status}
|
|
59
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Authenticate with the Finrock API
|
|
3
|
+
summary: Create an API user, generate an RSA key pair, and sign every request with a short-lived JWT.
|
|
4
|
+
docs: quickstart, api-reference/authentication, guides/whitelist-ips, reference/http-status-codes
|
|
5
|
+
client: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Every Finrock API call (except the public utilities such as Supported Assets and Asset Price)
|
|
9
|
+
needs two headers:
|
|
10
|
+
|
|
11
|
+
- `x-api-key`: the API Key UUID of an API user.
|
|
12
|
+
- `Authorization: Bearer <JWT>`: a JWT signed with **RS256** using your own RSA-4096 private key.
|
|
13
|
+
|
|
14
|
+
### One-time setup
|
|
15
|
+
|
|
16
|
+
1. Generate a key pair. The private key never leaves your servers.
|
|
17
|
+
```bash
|
|
18
|
+
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out finrock_private.pem
|
|
19
|
+
openssl pkey -in finrock_private.pem -pubout -out finrock_public.pem
|
|
20
|
+
```
|
|
21
|
+
2. In the Finrock Dashboard, open **API Users**, create a user, upload `finrock_public.pem`
|
|
22
|
+
and copy the API Key UUID (it is shown only once).
|
|
23
|
+
3. Store both as server-side secrets:
|
|
24
|
+
```bash
|
|
25
|
+
FINROCK_API_KEY=4466c45a-... # the API Key UUID
|
|
26
|
+
FINROCK_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
|
27
|
+
```
|
|
28
|
+
4. Optional but recommended: restrict the key to your server IPs (see *Whitelist IPs*).
|
|
29
|
+
|
|
30
|
+
### Rules for the JWT
|
|
31
|
+
|
|
32
|
+
| Claim | Value |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `uri` | The request path, e.g. `/dac/v1/invoice` |
|
|
35
|
+
| `nonce` | A new random value (UUID) for every request; reuse is rejected |
|
|
36
|
+
| `iat` | Now, in seconds since the epoch |
|
|
37
|
+
| `exp` | At most `iat + 30`; use `iat + 25` to allow for clock skew |
|
|
38
|
+
| `sub` | Your API Key UUID |
|
|
39
|
+
| `bodyHash` | Hex SHA-256 of the exact raw body string you send (`""` when there is no body) |
|
|
40
|
+
|
|
41
|
+
Build a new JWT for every request. Serialise the body once, hash that exact string, and send
|
|
42
|
+
that same string; pretty-printing or re-serialising after hashing causes a `bodyHash mismatch` 401.
|
|
43
|
+
|
|
44
|
+
### Never do this
|
|
45
|
+
|
|
46
|
+
- Never call the Finrock API from browser or mobile code; the private key and API key would leak.
|
|
47
|
+
Browsers use the Payment Widget or a hosted invoice `payment_url` instead.
|
|
48
|
+
- Never commit `finrock_private.pem` or put it in client-side environment variables
|
|
49
|
+
(`NEXT_PUBLIC_*`, `VITE_*`, and so on).
|
|
50
|
+
|
|
51
|
+
### Troubleshooting 401s
|
|
52
|
+
|
|
53
|
+
- *Token expired or window too large*: server clock drift, or `exp` more than 30 s after `iat`.
|
|
54
|
+
- *Invalid signature*: wrong algorithm (must be RS256) or the uploaded public key doesn't match.
|
|
55
|
+
- *bodyHash mismatch*: the body sent differs from the string that was hashed.
|
|
56
|
+
- *Nonce already used*: a JWT was reused, e.g. on a retry. Sign again for each attempt.
|
|
57
|
+
|
|
58
|
+
> Open question to confirm with Finrock: for GET requests with a query string, this client signs
|
|
59
|
+
> the path including the query (e.g. `/dac/v1/invoice?id=...`). If you get a signature 401 on
|
|
60
|
+
> such a call, try signing the path without the query string.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
### Address provisioning (`deposit-addresses.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { finrock } from "@/lib/finrock";
|
|
5
|
+
|
|
6
|
+
// One-time setup per asset (run from a script). Errors if the wallet already exists.
|
|
7
|
+
export async function createWallet(asset: string) {
|
|
8
|
+
return finrock("POST", "/dac/v1/wallet", { asset });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Get or create the deposit address for a customer and asset.
|
|
12
|
+
export async function depositAddressFor(customerId: string, asset: string) {
|
|
13
|
+
const existing = await db.depositAddresses.find({ customerId, asset }); // your data layer
|
|
14
|
+
if (existing) return existing;
|
|
15
|
+
|
|
16
|
+
const created = await finrock("POST", "/dac/v1/generate-address", {
|
|
17
|
+
asset, // e.g. "USDT_TRX"
|
|
18
|
+
label: `customer-${customerId}`,
|
|
19
|
+
omnibus: false,
|
|
20
|
+
auto_refill: true,
|
|
21
|
+
});
|
|
22
|
+
return db.depositAddresses.insert({
|
|
23
|
+
customerId,
|
|
24
|
+
asset,
|
|
25
|
+
address: created.address,
|
|
26
|
+
addressTag: created.address_tag, // needed for XRP / XLM
|
|
27
|
+
label: created.label,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Show it to the customer (`app/wallet/[asset]/page.tsx`)
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { depositAddressFor } from "@/lib/deposit-addresses";
|
|
36
|
+
import { getCurrentUser } from "@/lib/auth"; // your auth
|
|
37
|
+
|
|
38
|
+
export default async function DepositPage({ params }: { params: Promise<{ asset: string }> }) {
|
|
39
|
+
const { asset } = await params;
|
|
40
|
+
const user = await getCurrentUser();
|
|
41
|
+
const addr = await depositAddressFor(user.id, asset);
|
|
42
|
+
return (
|
|
43
|
+
<div>
|
|
44
|
+
<p>Send only {asset} to this address:</p>
|
|
45
|
+
<code>{addr.address}</code>
|
|
46
|
+
{addr.addressTag && <p>Destination tag / memo: <code>{addr.addressTag}</code></p>}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
### Address provisioning (`deposit-addresses.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { finrock } from "./finrock";
|
|
5
|
+
|
|
6
|
+
// One-time setup per asset (run from a script). Errors if the wallet already exists.
|
|
7
|
+
export async function createWallet(asset: string) {
|
|
8
|
+
return finrock("POST", "/dac/v1/wallet", { asset });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Get or create the deposit address for a customer and asset.
|
|
12
|
+
export async function depositAddressFor(customerId: string, asset: string) {
|
|
13
|
+
const existing = await db.depositAddresses.find({ customerId, asset }); // your data layer
|
|
14
|
+
if (existing) return existing;
|
|
15
|
+
|
|
16
|
+
const created = await finrock("POST", "/dac/v1/generate-address", {
|
|
17
|
+
asset, // e.g. "USDT_TRX"
|
|
18
|
+
label: `customer-${customerId}`,
|
|
19
|
+
omnibus: false,
|
|
20
|
+
auto_refill: true,
|
|
21
|
+
});
|
|
22
|
+
return db.depositAddresses.insert({
|
|
23
|
+
customerId,
|
|
24
|
+
asset,
|
|
25
|
+
address: created.address,
|
|
26
|
+
addressTag: created.address_tag, // needed for XRP / XLM
|
|
27
|
+
label: created.label,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Give each customer a crypto deposit address
|
|
3
|
+
summary: Create one wallet per asset, generate a labelled deposit address per customer, and credit them from Deposit webhooks.
|
|
4
|
+
docs: guides/create-wallet-address, api-reference/wallets/create-wallet, api-reference/wallets/create-address, concepts/omnibus-wallets, concepts/gas-tanks, reference/supported-assets
|
|
5
|
+
client: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use this for top-ups, exchange-style balances, or any app where customers send crypto to an
|
|
9
|
+
address that belongs to them.
|
|
10
|
+
|
|
11
|
+
### Flow
|
|
12
|
+
|
|
13
|
+
1. **One-time: create a wallet per asset** with `POST /dac/v1/wallet` `{ "asset": "USDT_TRX" }`.
|
|
14
|
+
A sub-account can hold only one wallet per asset, so creating it twice errors. Do this
|
|
15
|
+
once from a setup script or the dashboard, not on every request.
|
|
16
|
+
2. **Per customer: generate an address** with `POST /dac/v1/generate-address`:
|
|
17
|
+
- `asset`: the asset name, e.g. `USDT_TRX`, `ETH`, `BTC`.
|
|
18
|
+
- `label`: your customer ID (e.g. `customer-42`) so deposits can be reconciled.
|
|
19
|
+
- `omnibus`: `false` (default) keeps an isolated address; `true` adds it to the shared pool.
|
|
20
|
+
- `auto_refill`: `true` tops up gas from your Gas Tank when needed (always on for omnibus).
|
|
21
|
+
3. **Store** the returned `address` (and `address_tag`, which XRP/XLM need as a destination tag)
|
|
22
|
+
with the customer and show it to them, ideally with a QR code.
|
|
23
|
+
4. **Credit deposits from webhooks** (see the `webhooks` guide): on a `Deposit` event with
|
|
24
|
+
status `Success`, find the customer by the deposit address or label, then credit `amount`
|
|
25
|
+
once per event `id`.
|
|
26
|
+
|
|
27
|
+
### Choosing omnibus vs isolated
|
|
28
|
+
|
|
29
|
+
- **Isolated** keeps each customer's funds on their own address: clean per-customer audit trail,
|
|
30
|
+
but each address needs gas to move funds on EVM/Tron chains (Gas Tanks handle this).
|
|
31
|
+
- **Omnibus** pools deposits for simpler treasury management and is typical for high volume.
|
|
32
|
+
|
|
33
|
+
Read *Omnibus vs Isolated Addresses* and *Gas Tanks* before choosing for production.
|
|
34
|
+
|
|
35
|
+
> Confirm with a test deposit which webhook fields carry your address and label
|
|
36
|
+
> (`dest_id` / `dest_alias` in the samples) before relying on them for crediting.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
### Address provisioning (`deposit_addresses.php`)
|
|
2
|
+
|
|
3
|
+
```php
|
|
4
|
+
<?php
|
|
5
|
+
require_once __DIR__ . '/finrock.php';
|
|
6
|
+
require_once __DIR__ . '/db.php'; // your data layer
|
|
7
|
+
|
|
8
|
+
// One-time setup per asset (run from a script). Errors if the wallet already exists.
|
|
9
|
+
function create_wallet(string $asset): array
|
|
10
|
+
{
|
|
11
|
+
return finrock('POST', '/dac/v1/wallet', ['asset' => $asset]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Get or create the deposit address for a customer and asset.
|
|
15
|
+
function deposit_address_for(string $customerId, string $asset): array
|
|
16
|
+
{
|
|
17
|
+
$existing = db_find_deposit_address($customerId, $asset);
|
|
18
|
+
if ($existing) {
|
|
19
|
+
return $existing;
|
|
20
|
+
}
|
|
21
|
+
$created = finrock('POST', '/dac/v1/generate-address', [
|
|
22
|
+
'asset' => $asset, // e.g. "USDT_TRX"
|
|
23
|
+
'label' => "customer-$customerId",
|
|
24
|
+
'omnibus' => false,
|
|
25
|
+
'auto_refill' => true,
|
|
26
|
+
]);
|
|
27
|
+
return db_insert_deposit_address([
|
|
28
|
+
'customer_id' => $customerId,
|
|
29
|
+
'asset' => $asset,
|
|
30
|
+
'address' => $created['address'],
|
|
31
|
+
'address_tag' => $created['address_tag'] ?? null, // needed for XRP / XLM
|
|
32
|
+
'label' => $created['label'],
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
```
|