@finrock/mcp 0.0.0-stage → 0.1.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,31 @@
|
|
|
1
|
+
### Address provisioning (`deposit_addresses.py`)
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from finrock import finrock
|
|
5
|
+
from mydb import db # your data layer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_wallet(asset: str):
|
|
9
|
+
"""One-time setup per asset (run from a script). Errors if the wallet already exists."""
|
|
10
|
+
return finrock("POST", "/dac/v1/wallet", {"asset": asset})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def deposit_address_for(customer_id: str, asset: str):
|
|
14
|
+
"""Get or create the deposit address for a customer and asset."""
|
|
15
|
+
existing = db.find_deposit_address(customer_id, asset)
|
|
16
|
+
if existing:
|
|
17
|
+
return existing
|
|
18
|
+
created = finrock("POST", "/dac/v1/generate-address", {
|
|
19
|
+
"asset": asset, # e.g. "USDT_TRX"
|
|
20
|
+
"label": f"customer-{customer_id}",
|
|
21
|
+
"omnibus": False,
|
|
22
|
+
"auto_refill": True,
|
|
23
|
+
})
|
|
24
|
+
return db.insert_deposit_address(
|
|
25
|
+
customer_id=customer_id,
|
|
26
|
+
asset=asset,
|
|
27
|
+
address=created["address"],
|
|
28
|
+
address_tag=created.get("address_tag"), # needed for XRP / XLM
|
|
29
|
+
label=created["label"],
|
|
30
|
+
)
|
|
31
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
### Plain HTML (works with any backend)
|
|
2
|
+
|
|
3
|
+
```html
|
|
4
|
+
<div id="finrock-widget"></div>
|
|
5
|
+
<script src="https://finrock.io/widget/widget.js"></script>
|
|
6
|
+
<script>
|
|
7
|
+
loadWidget({
|
|
8
|
+
widget_id: "YOUR_WIDGET_ID",
|
|
9
|
+
fiat_amount: 49.99,
|
|
10
|
+
crypto_asset: "USDT_TRX",
|
|
11
|
+
payer_email: "jane@example.com",
|
|
12
|
+
desc: "order-1042",
|
|
13
|
+
}).then(
|
|
14
|
+
(result) => {
|
|
15
|
+
// UX only: your server confirms the payment from the webhook.
|
|
16
|
+
window.location.href = "/orders/1042/thanks";
|
|
17
|
+
},
|
|
18
|
+
(err) => {
|
|
19
|
+
console.error("Payment failed or cancelled", err);
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
</script>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Render the order values (`fiat_amount`, `desc`, `payer_email`) from your server-side template,
|
|
26
|
+
escaped for JavaScript.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
### Client component (`components/finrock-widget.tsx`)
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
"use client";
|
|
5
|
+
import Script from "next/script";
|
|
6
|
+
import { useRouter } from "next/navigation";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
loadWidget?: (opts: Record<string, unknown>) => Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FinrockWidget(props: { orderId: string; amount: number; asset?: string; email?: string }) {
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const [ready, setReady] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!ready || !window.loadWidget) return;
|
|
21
|
+
window
|
|
22
|
+
.loadWidget({
|
|
23
|
+
widget_id: process.env.NEXT_PUBLIC_FINROCK_WIDGET_ID, // the widget ID is safe to expose
|
|
24
|
+
fiat_amount: props.amount,
|
|
25
|
+
crypto_asset: props.asset,
|
|
26
|
+
payer_email: props.email,
|
|
27
|
+
desc: `order-${props.orderId}`,
|
|
28
|
+
})
|
|
29
|
+
// UX only: your server confirms the payment from the webhook.
|
|
30
|
+
.then(() => router.push(`/orders/${props.orderId}/thanks`))
|
|
31
|
+
.catch((err) => console.error("Payment failed or cancelled", err));
|
|
32
|
+
}, [ready, props.amount, props.asset, props.email, props.orderId, router]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<Script src="https://finrock.io/widget/widget.js" strategy="afterInteractive" onReady={() => setReady(true)} />
|
|
37
|
+
<div id="finrock-widget" />
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Embed the Finrock payment widget
|
|
3
|
+
summary: Add a drop-in crypto checkout form to any web page with one script tag and a widget ID; no API keys in the browser.
|
|
4
|
+
docs: guides/payment-widget, reference/supported-assets
|
|
5
|
+
client: false
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
The widget is the fastest way to accept crypto on a website. It needs only a **widget ID**
|
|
9
|
+
from the Finrock dashboard (ask your account manager if you don't see one); no API key,
|
|
10
|
+
private key or server code is required to show it.
|
|
11
|
+
|
|
12
|
+
### Options for `loadWidget()`
|
|
13
|
+
|
|
14
|
+
| Option | Type | Purpose |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `widget_id` | string | **Required.** Your workspace widget ID |
|
|
17
|
+
| `fiat_amount` | number | Pre-fill the amount in local currency, e.g. `50.00` |
|
|
18
|
+
| `crypto_amount` | number | Pre-fill the crypto amount, e.g. `0.001` |
|
|
19
|
+
| `crypto_asset` | string | Pre-select the coin, e.g. `BTC`, `ETH`, `USDT_TRX` |
|
|
20
|
+
| `payer_name` / `payer_email` / `payer_mobile` | string / string / number | Pre-fill payer details |
|
|
21
|
+
| `desc` | string | Note to you, e.g. your order or invoice number |
|
|
22
|
+
|
|
23
|
+
`loadWidget()` returns a Promise: it resolves with the payment result and rejects on failure
|
|
24
|
+
or cancellation.
|
|
25
|
+
|
|
26
|
+
### Important
|
|
27
|
+
|
|
28
|
+
- Treat the browser callback as a UX signal only. Confirm the payment on your server from a
|
|
29
|
+
verified webhook (see the `webhooks` guide) before fulfilling, and put your order ID in `desc`
|
|
30
|
+
so you can match it.
|
|
31
|
+
- If you need per-order amounts that customers can't edit, or a redirect-based flow, use the
|
|
32
|
+
`accept-payments` (hosted invoice) guide instead.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
### Route Handler (`app/api/webhooks/finrock/route.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { createPublicKey, verify, constants } from "node:crypto";
|
|
5
|
+
import { db } from "@/lib/db"; // your data layer
|
|
6
|
+
|
|
7
|
+
export const runtime = "nodejs";
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
|
|
10
|
+
// Finrock's webhook signing key (https://apidocs.finrock.io/guides/webhooks)
|
|
11
|
+
const FINROCK_WEBHOOK_KEY = createPublicKey(`-----BEGIN PUBLIC KEY-----
|
|
12
|
+
MIIBnzANBgkqhkiG9w0BAQEFAAOCAYwAMIIBhwKCAX4Aq1O7ztZxzkgDCFlUnHvs
|
|
13
|
+
EBffqmlng9uEmNMCGbCpxB7991WiR4TRRVaTUrLxIzPWl79xT6R2Xzba1GqN2bin
|
|
14
|
+
sPBU/cmHEF5v1bDopZ7oHnU43tnlpH1kSOjtvTBORf6v6+9xh87OYtg4wXuwVzCq
|
|
15
|
+
ykXDRPIIzgPCSpA7l7DtPqhOpm0OVpUnVJQ5DgnsMfJMPkrrPTjRkpFZ1RbtqoLW
|
|
16
|
+
UI+bMWZAAKLEsh4Q+SRO7xiHj26gToD+/ZrEAbC/SOcy2ChDfpQ39XN/bYTu52j8
|
|
17
|
+
ndAS/R4qnffLGpmTcxr19UC+BPs5Wb5kx2ZJsGPQPpoPHaTBC1n3YwQlksgeHEvX
|
|
18
|
+
ZBsDoAvIXpGfBjUPzd8bvz5vyxbLYA4lSqp/Fw48NYgRRgfOWjmtqVI07jnXKxU2
|
|
19
|
+
kcQc7QkoFVDYw45DS14tkrsCOJjMF0qux2dL9Gr1z/Xdaod+SyeRqsTDYhbzaD+v
|
|
20
|
+
UkO6chtsIHmQwAF8jYajAR7uKROX+6f8ZIwYDco3AgMBAAE=
|
|
21
|
+
-----END PUBLIC KEY-----`);
|
|
22
|
+
|
|
23
|
+
export async function POST(req: Request) {
|
|
24
|
+
// Read raw bytes; don't call req.json() before verifying.
|
|
25
|
+
const raw = Buffer.from(await req.arrayBuffer());
|
|
26
|
+
const signature = req.headers.get("x-signature") ?? "";
|
|
27
|
+
const ok = verify(
|
|
28
|
+
"sha512",
|
|
29
|
+
raw,
|
|
30
|
+
{ key: FINROCK_WEBHOOK_KEY, padding: constants.RSA_PKCS1_PADDING },
|
|
31
|
+
Buffer.from(signature, "base64"),
|
|
32
|
+
);
|
|
33
|
+
if (!ok) return new Response("invalid signature", { status: 401 });
|
|
34
|
+
|
|
35
|
+
const event = JSON.parse(raw.toString("utf8"));
|
|
36
|
+
|
|
37
|
+
// Deduplicate: retries resend the same event, and each status change is its own event.
|
|
38
|
+
const firstTime = await db.webhookEvents.insertIfAbsent(`${event.id}:${event.status}`);
|
|
39
|
+
if (!firstTime) return new Response("ok");
|
|
40
|
+
|
|
41
|
+
if (event.type === "Deposit" && event.status === "Success") {
|
|
42
|
+
await creditDeposit(event); // your logic, in a transaction
|
|
43
|
+
} else if (event.type === "Withdraw") {
|
|
44
|
+
await db.withdrawals.updateBySequenceId(event.sequence_id, { status: event.status, hash: event.hash });
|
|
45
|
+
}
|
|
46
|
+
return new Response("ok");
|
|
47
|
+
}
|
|
48
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
### Express endpoint (`webhooks.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { createPublicKey, verify, constants } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// Finrock's webhook signing key (https://apidocs.finrock.io/guides/webhooks)
|
|
8
|
+
const FINROCK_WEBHOOK_KEY = createPublicKey(`-----BEGIN PUBLIC KEY-----
|
|
9
|
+
MIIBnzANBgkqhkiG9w0BAQEFAAOCAYwAMIIBhwKCAX4Aq1O7ztZxzkgDCFlUnHvs
|
|
10
|
+
EBffqmlng9uEmNMCGbCpxB7991WiR4TRRVaTUrLxIzPWl79xT6R2Xzba1GqN2bin
|
|
11
|
+
sPBU/cmHEF5v1bDopZ7oHnU43tnlpH1kSOjtvTBORf6v6+9xh87OYtg4wXuwVzCq
|
|
12
|
+
ykXDRPIIzgPCSpA7l7DtPqhOpm0OVpUnVJQ5DgnsMfJMPkrrPTjRkpFZ1RbtqoLW
|
|
13
|
+
UI+bMWZAAKLEsh4Q+SRO7xiHj26gToD+/ZrEAbC/SOcy2ChDfpQ39XN/bYTu52j8
|
|
14
|
+
ndAS/R4qnffLGpmTcxr19UC+BPs5Wb5kx2ZJsGPQPpoPHaTBC1n3YwQlksgeHEvX
|
|
15
|
+
ZBsDoAvIXpGfBjUPzd8bvz5vyxbLYA4lSqp/Fw48NYgRRgfOWjmtqVI07jnXKxU2
|
|
16
|
+
kcQc7QkoFVDYw45DS14tkrsCOJjMF0qux2dL9Gr1z/Xdaod+SyeRqsTDYhbzaD+v
|
|
17
|
+
UkO6chtsIHmQwAF8jYajAR7uKROX+6f8ZIwYDco3AgMBAAE=
|
|
18
|
+
-----END PUBLIC KEY-----`);
|
|
19
|
+
|
|
20
|
+
export const webhooks = express.Router();
|
|
21
|
+
|
|
22
|
+
// express.raw keeps the exact bytes; never put express.json() in front of this route.
|
|
23
|
+
webhooks.post("/webhooks/finrock", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => {
|
|
24
|
+
const signature = req.header("x-signature") ?? "";
|
|
25
|
+
const raw: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
26
|
+
const ok = verify(
|
|
27
|
+
"sha512",
|
|
28
|
+
raw,
|
|
29
|
+
{ key: FINROCK_WEBHOOK_KEY, padding: constants.RSA_PKCS1_PADDING },
|
|
30
|
+
Buffer.from(signature, "base64"),
|
|
31
|
+
);
|
|
32
|
+
if (!ok) return res.status(401).send("invalid signature");
|
|
33
|
+
|
|
34
|
+
const event = JSON.parse(raw.toString("utf8"));
|
|
35
|
+
|
|
36
|
+
// Deduplicate: retries resend the same event, and each status change is its own event.
|
|
37
|
+
const firstTime = await db.webhookEvents.insertIfAbsent(`${event.id}:${event.status}`);
|
|
38
|
+
if (!firstTime) return res.sendStatus(200);
|
|
39
|
+
|
|
40
|
+
if (event.type === "Deposit" && event.status === "Success") {
|
|
41
|
+
// Find the customer from the deposit address/label you created for them.
|
|
42
|
+
await creditDeposit(event); // your logic, in a transaction
|
|
43
|
+
} else if (event.type === "Withdraw") {
|
|
44
|
+
await db.withdrawals.updateBySequenceId(event.sequence_id, { status: event.status, hash: event.hash });
|
|
45
|
+
}
|
|
46
|
+
res.sendStatus(200);
|
|
47
|
+
});
|
|
48
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Receive and verify Finrock webhooks
|
|
3
|
+
summary: Get real-time deposit and withdrawal events, verify the x-signature header, and process each event exactly once.
|
|
4
|
+
docs: guides/webhooks, reference/transaction-statuses, reference/transaction-types
|
|
5
|
+
client: false
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Finrock POSTs a JSON event to your endpoint whenever a deposit arrives or a withdrawal changes
|
|
9
|
+
status. Register the URL at **https://finrock.io/webhooks** (enter the URL, press **+**).
|
|
10
|
+
|
|
11
|
+
### Rules
|
|
12
|
+
|
|
13
|
+
1. **Verify every request.** The `x-signature` header is a base64 RSA-SHA512 (PKCS#1 v1.5)
|
|
14
|
+
signature of the raw body, made with Finrock's key. The public key is in the code below.
|
|
15
|
+
2. **Verify the raw bytes.** Read the body *before* any JSON middleware touches it
|
|
16
|
+
(`express.raw()`, `await req.arrayBuffer()`, `await request.body()`, `php://input`).
|
|
17
|
+
3. **Return HTTP 200 quickly.** Anything else, or a timeout, is retried up to 5 times
|
|
18
|
+
(after 30 s, 60 s, 90 s, 120 s, 180 s). Do slow work in a queue.
|
|
19
|
+
4. **Be idempotent.** The same event can arrive more than once, and one transaction sends one
|
|
20
|
+
event per status change. Deduplicate on `id` + `status`.
|
|
21
|
+
5. **Act on terminal statuses.** Credit a deposit only when `status` is `Success`. `Failed`,
|
|
22
|
+
`Rejected`, `Blocked`, `UserCancelled` and `AML_Blocked` are also final; everything else
|
|
23
|
+
(`Pending`, `Processing`, `Signing`, `SweepPending`, `NeedsApproval`, `AML_Pending`) is in progress.
|
|
24
|
+
|
|
25
|
+
### Useful payload fields
|
|
26
|
+
|
|
27
|
+
`id`, `type` (`Deposit` or `Withdraw`), `asset` (e.g. `USDT_TRX`), `amount`, `amount_in_usd`,
|
|
28
|
+
`nett` (after fees, negative for withdrawals), `status`, `confirmations.{required,actual}`,
|
|
29
|
+
`hash`, `explorer_url`, `sequence_id` (your idempotency key for withdrawals),
|
|
30
|
+
`source_*`/`dest_*` (type, id, alias), `aml.cid`, `created_timestamp_utc`.
|
|
31
|
+
|
|
32
|
+
To test locally, expose your endpoint with a tunnel (for example `cloudflared` or `ngrok`) and
|
|
33
|
+
register the tunnel URL. The `verify_webhook_signature` tool on this server can check a captured
|
|
34
|
+
body and signature for you.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
### Endpoint (`finrock_webhook.php`)
|
|
2
|
+
|
|
3
|
+
```php
|
|
4
|
+
<?php
|
|
5
|
+
require __DIR__ . '/db.php'; // your data layer
|
|
6
|
+
|
|
7
|
+
// Finrock's webhook signing key (https://apidocs.finrock.io/guides/webhooks)
|
|
8
|
+
const FINROCK_WEBHOOK_KEY = <<<PEM
|
|
9
|
+
-----BEGIN PUBLIC KEY-----
|
|
10
|
+
MIIBnzANBgkqhkiG9w0BAQEFAAOCAYwAMIIBhwKCAX4Aq1O7ztZxzkgDCFlUnHvs
|
|
11
|
+
EBffqmlng9uEmNMCGbCpxB7991WiR4TRRVaTUrLxIzPWl79xT6R2Xzba1GqN2bin
|
|
12
|
+
sPBU/cmHEF5v1bDopZ7oHnU43tnlpH1kSOjtvTBORf6v6+9xh87OYtg4wXuwVzCq
|
|
13
|
+
ykXDRPIIzgPCSpA7l7DtPqhOpm0OVpUnVJQ5DgnsMfJMPkrrPTjRkpFZ1RbtqoLW
|
|
14
|
+
UI+bMWZAAKLEsh4Q+SRO7xiHj26gToD+/ZrEAbC/SOcy2ChDfpQ39XN/bYTu52j8
|
|
15
|
+
ndAS/R4qnffLGpmTcxr19UC+BPs5Wb5kx2ZJsGPQPpoPHaTBC1n3YwQlksgeHEvX
|
|
16
|
+
ZBsDoAvIXpGfBjUPzd8bvz5vyxbLYA4lSqp/Fw48NYgRRgfOWjmtqVI07jnXKxU2
|
|
17
|
+
kcQc7QkoFVDYw45DS14tkrsCOJjMF0qux2dL9Gr1z/Xdaod+SyeRqsTDYhbzaD+v
|
|
18
|
+
UkO6chtsIHmQwAF8jYajAR7uKROX+6f8ZIwYDco3AgMBAAE=
|
|
19
|
+
-----END PUBLIC KEY-----
|
|
20
|
+
PEM;
|
|
21
|
+
|
|
22
|
+
$raw = file_get_contents('php://input'); // exact bytes
|
|
23
|
+
$signature = base64_decode($_SERVER['HTTP_X_SIGNATURE'] ?? '', true);
|
|
24
|
+
|
|
25
|
+
if ($signature === false || openssl_verify($raw, $signature, FINROCK_WEBHOOK_KEY, OPENSSL_ALGO_SHA512) !== 1) {
|
|
26
|
+
http_response_code(401);
|
|
27
|
+
exit('invalid signature');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$event = json_decode($raw, true);
|
|
31
|
+
|
|
32
|
+
// Deduplicate: retries resend the same event, and each status change is its own event.
|
|
33
|
+
if (!db_insert_webhook_event_if_absent($event['id'] . ':' . $event['status'])) {
|
|
34
|
+
http_response_code(200);
|
|
35
|
+
exit('ok');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if ($event['type'] === 'Deposit' && $event['status'] === 'Success') {
|
|
39
|
+
credit_deposit($event); // your logic, in a transaction
|
|
40
|
+
} elseif ($event['type'] === 'Withdraw') {
|
|
41
|
+
db_update_withdrawal($event['sequence_id'] ?? null, $event['status'], $event['hash'] ?? null);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
http_response_code(200);
|
|
45
|
+
echo 'ok';
|
|
46
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
### FastAPI endpoint (`webhooks.py`)
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install cryptography
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import base64
|
|
9
|
+
import binascii
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from cryptography.exceptions import InvalidSignature
|
|
13
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
15
|
+
from fastapi import APIRouter, Request, Response
|
|
16
|
+
|
|
17
|
+
from mydb import db, credit_deposit # your data layer
|
|
18
|
+
|
|
19
|
+
# Finrock's webhook signing key (https://apidocs.finrock.io/guides/webhooks)
|
|
20
|
+
FINROCK_WEBHOOK_KEY = serialization.load_pem_public_key(b"""-----BEGIN PUBLIC KEY-----
|
|
21
|
+
MIIBnzANBgkqhkiG9w0BAQEFAAOCAYwAMIIBhwKCAX4Aq1O7ztZxzkgDCFlUnHvs
|
|
22
|
+
EBffqmlng9uEmNMCGbCpxB7991WiR4TRRVaTUrLxIzPWl79xT6R2Xzba1GqN2bin
|
|
23
|
+
sPBU/cmHEF5v1bDopZ7oHnU43tnlpH1kSOjtvTBORf6v6+9xh87OYtg4wXuwVzCq
|
|
24
|
+
ykXDRPIIzgPCSpA7l7DtPqhOpm0OVpUnVJQ5DgnsMfJMPkrrPTjRkpFZ1RbtqoLW
|
|
25
|
+
UI+bMWZAAKLEsh4Q+SRO7xiHj26gToD+/ZrEAbC/SOcy2ChDfpQ39XN/bYTu52j8
|
|
26
|
+
ndAS/R4qnffLGpmTcxr19UC+BPs5Wb5kx2ZJsGPQPpoPHaTBC1n3YwQlksgeHEvX
|
|
27
|
+
ZBsDoAvIXpGfBjUPzd8bvz5vyxbLYA4lSqp/Fw48NYgRRgfOWjmtqVI07jnXKxU2
|
|
28
|
+
kcQc7QkoFVDYw45DS14tkrsCOJjMF0qux2dL9Gr1z/Xdaod+SyeRqsTDYhbzaD+v
|
|
29
|
+
UkO6chtsIHmQwAF8jYajAR7uKROX+6f8ZIwYDco3AgMBAAE=
|
|
30
|
+
-----END PUBLIC KEY-----""")
|
|
31
|
+
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.post("/webhooks/finrock")
|
|
36
|
+
async def finrock_webhook(request: Request):
|
|
37
|
+
raw = await request.body() # exact bytes; don't use request.json() before verifying
|
|
38
|
+
try:
|
|
39
|
+
signature = base64.b64decode(request.headers.get("x-signature", ""), validate=True)
|
|
40
|
+
FINROCK_WEBHOOK_KEY.verify(signature, raw, padding.PKCS1v15(), hashes.SHA512())
|
|
41
|
+
except (InvalidSignature, binascii.Error, ValueError):
|
|
42
|
+
return Response("invalid signature", status_code=401)
|
|
43
|
+
|
|
44
|
+
event = json.loads(raw)
|
|
45
|
+
|
|
46
|
+
# Deduplicate: retries resend the same event, and each status change is its own event.
|
|
47
|
+
if not db.insert_webhook_event_if_absent(f"{event['id']}:{event['status']}"):
|
|
48
|
+
return Response("ok")
|
|
49
|
+
|
|
50
|
+
if event["type"] == "Deposit" and event["status"] == "Success":
|
|
51
|
+
credit_deposit(event) # your logic, in a transaction
|
|
52
|
+
elif event["type"] == "Withdraw":
|
|
53
|
+
db.update_withdrawal(event.get("sequence_id"), status=event["status"], tx_hash=event.get("hash"))
|
|
54
|
+
return Response("ok")
|
|
55
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
### Payouts (`payouts.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { finrock } from "@/lib/finrock";
|
|
5
|
+
|
|
6
|
+
const TERMINAL = new Set(["Success", "Failed", "Rejected", "Blocked", "UserCancelled", "AML_Blocked"]);
|
|
7
|
+
|
|
8
|
+
export async function sendPayout(p: {
|
|
9
|
+
payoutId: string; // your unique ID; becomes Finrock's sequence_id
|
|
10
|
+
customerId: string;
|
|
11
|
+
asset: string; // e.g. "USDT_TRX"
|
|
12
|
+
amount: number; // native units
|
|
13
|
+
address: string;
|
|
14
|
+
tag?: string; // XRP / XLM destination tag
|
|
15
|
+
}) {
|
|
16
|
+
const tx = await finrock("POST", "/dac/v1/transaction", {
|
|
17
|
+
asset: p.asset,
|
|
18
|
+
amount: p.amount,
|
|
19
|
+
source_type: "INTERNAL",
|
|
20
|
+
destination_type: "EXTERNAL",
|
|
21
|
+
destination_id: p.address,
|
|
22
|
+
...(p.tag ? { destination_tag: p.tag } : {}),
|
|
23
|
+
sequence_id: p.payoutId, // safe to retry: same ID returns the same transaction
|
|
24
|
+
aml_cid: p.customerId,
|
|
25
|
+
memo: `payout-${p.payoutId}`,
|
|
26
|
+
});
|
|
27
|
+
await db.payouts.update(p.payoutId, { finrockId: tx.id, status: tx.status });
|
|
28
|
+
return tx;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const isTerminal = (status: string) => TERMINAL.has(status);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Prefer webhooks (`Withdraw` events carry your `sequence_id`) to update the payout status.
|
|
35
|
+
If you must poll, back off from 5 s to 60 s and stop at a terminal status.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
### Payouts (`payouts.ts`)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { finrock } from "./finrock";
|
|
5
|
+
|
|
6
|
+
const TERMINAL = new Set(["Success", "Failed", "Rejected", "Blocked", "UserCancelled", "AML_Blocked"]);
|
|
7
|
+
|
|
8
|
+
export async function sendPayout(p: {
|
|
9
|
+
payoutId: string; // your unique ID; becomes Finrock's sequence_id
|
|
10
|
+
customerId: string;
|
|
11
|
+
asset: string; // e.g. "USDT_TRX"
|
|
12
|
+
amount: number; // native units
|
|
13
|
+
address: string;
|
|
14
|
+
tag?: string; // XRP / XLM destination tag
|
|
15
|
+
}) {
|
|
16
|
+
const tx = await finrock("POST", "/dac/v1/transaction", {
|
|
17
|
+
asset: p.asset,
|
|
18
|
+
amount: p.amount,
|
|
19
|
+
source_type: "INTERNAL",
|
|
20
|
+
destination_type: "EXTERNAL",
|
|
21
|
+
destination_id: p.address,
|
|
22
|
+
...(p.tag ? { destination_tag: p.tag } : {}),
|
|
23
|
+
sequence_id: p.payoutId, // safe to retry: same ID returns the same transaction
|
|
24
|
+
aml_cid: p.customerId,
|
|
25
|
+
memo: `payout-${p.payoutId}`,
|
|
26
|
+
});
|
|
27
|
+
await db.payouts.update(p.payoutId, { finrockId: tx.id, status: tx.status });
|
|
28
|
+
return tx;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const isTerminal = (status: string) => TERMINAL.has(status);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Prefer webhooks (`Withdraw` events carry your `sequence_id`) to update the payout status.
|
|
35
|
+
If you must poll, back off from 5 s to 60 s and stop at a terminal status.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Send crypto payouts and withdrawals
|
|
3
|
+
summary: Validate the address, create an idempotent transaction, and track it to a terminal status.
|
|
4
|
+
docs: guides/send-transaction, api-reference/transactions/create-transaction, api-reference/transactions/estimate-fee, api-reference/wallets/validate-address, reference/transaction-statuses, reference/transaction-types, reference/idempotency, guides/ad-hoc-addresses
|
|
5
|
+
client: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### Flow
|
|
9
|
+
|
|
10
|
+
1. **Validate the destination** with *Validate Address* before accepting it from a user.
|
|
11
|
+
2. **Optionally estimate the fee** with *Estimate Fee* and show it to the user.
|
|
12
|
+
3. **Create the transaction** with `POST /dac/v1/transaction`:
|
|
13
|
+
- `asset`, `amount` (native units, up to 8 decimals)
|
|
14
|
+
- `source_type`: `INTERNAL` (your wallet) or `EXCHANGE`
|
|
15
|
+
- `destination_type`: `EXTERNAL` for a customer's address (`INTERNAL`, `SUBACCOUNT`, `EXCHANGE` for your own)
|
|
16
|
+
- `destination_id`: the address; add `destination_tag` for XRP/XLM
|
|
17
|
+
- `sequence_id`: **always set this** to your own payout ID. If you retry with the same
|
|
18
|
+
`sequence_id`, Finrock returns the existing transaction instead of paying twice.
|
|
19
|
+
- `aml_cid`: your customer ID; required when AML screening is enabled.
|
|
20
|
+
4. **Track it** by webhook (preferred) or by polling the transaction with backoff
|
|
21
|
+
(5 s doubling to 60 s) until a terminal status:
|
|
22
|
+
`Success`, `Failed`, `Rejected`, `Blocked`, `UserCancelled`, `AML_Blocked`.
|
|
23
|
+
`NeedsApproval` and `AML_Pending` mean wait; never resubmit with a new `sequence_id`.
|
|
24
|
+
|
|
25
|
+
### Before going live
|
|
26
|
+
|
|
27
|
+
- Withdrawals to non-whitelisted addresses need *Ad-Hoc Addresses* enabled, and withdrawals to
|
|
28
|
+
smart contracts are blocked by default (see *Contract Addresses*).
|
|
29
|
+
- Debit the user's balance in the same database transaction that records the payout, and
|
|
30
|
+
refund it only on a terminal failure status.
|
|
31
|
+
- Bulk payouts on UTXO chains (BTC etc.) can use `destinations: [{ address, amount }, ...]`
|
|
32
|
+
instead of `destination_id`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
### Payouts (`payouts.php`)
|
|
2
|
+
|
|
3
|
+
```php
|
|
4
|
+
<?php
|
|
5
|
+
require_once __DIR__ . '/finrock.php';
|
|
6
|
+
require_once __DIR__ . '/db.php'; // your data layer
|
|
7
|
+
|
|
8
|
+
const FINROCK_TERMINAL = ['Success', 'Failed', 'Rejected', 'Blocked', 'UserCancelled', 'AML_Blocked'];
|
|
9
|
+
|
|
10
|
+
function send_payout(string $payoutId, string $customerId, string $asset, float $amount, string $address, ?string $tag = null): array
|
|
11
|
+
{
|
|
12
|
+
$body = [
|
|
13
|
+
'asset' => $asset, // e.g. "USDT_TRX"
|
|
14
|
+
'amount' => $amount, // native units
|
|
15
|
+
'source_type' => 'INTERNAL',
|
|
16
|
+
'destination_type' => 'EXTERNAL',
|
|
17
|
+
'destination_id' => $address,
|
|
18
|
+
'sequence_id' => $payoutId, // safe to retry: same ID returns the same transaction
|
|
19
|
+
'aml_cid' => $customerId,
|
|
20
|
+
'memo' => "payout-$payoutId",
|
|
21
|
+
];
|
|
22
|
+
if ($tag !== null) {
|
|
23
|
+
$body['destination_tag'] = $tag; // XRP / XLM
|
|
24
|
+
}
|
|
25
|
+
$tx = finrock('POST', '/dac/v1/transaction', $body);
|
|
26
|
+
db_update_payout($payoutId, ['finrock_id' => $tx['id'], 'status' => $tx['status']]);
|
|
27
|
+
return $tx;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function is_terminal(string $status): bool
|
|
31
|
+
{
|
|
32
|
+
return in_array($status, FINROCK_TERMINAL, true);
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Prefer webhooks (`Withdraw` events carry your `sequence_id`) to update the payout status.
|
|
37
|
+
If you must poll, back off from 5 s to 60 s and stop at a terminal status.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
### Payouts (`payouts.py`)
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from finrock import finrock
|
|
5
|
+
from mydb import db # your data layer
|
|
6
|
+
|
|
7
|
+
TERMINAL = {"Success", "Failed", "Rejected", "Blocked", "UserCancelled", "AML_Blocked"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def send_payout(payout_id: str, customer_id: str, asset: str, amount: float, address: str, tag: str | None = None):
|
|
11
|
+
body = {
|
|
12
|
+
"asset": asset, # e.g. "USDT_TRX"
|
|
13
|
+
"amount": amount, # native units
|
|
14
|
+
"source_type": "INTERNAL",
|
|
15
|
+
"destination_type": "EXTERNAL",
|
|
16
|
+
"destination_id": address,
|
|
17
|
+
"sequence_id": payout_id, # safe to retry: same ID returns the same transaction
|
|
18
|
+
"aml_cid": customer_id,
|
|
19
|
+
"memo": f"payout-{payout_id}",
|
|
20
|
+
}
|
|
21
|
+
if tag:
|
|
22
|
+
body["destination_tag"] = tag # XRP / XLM
|
|
23
|
+
tx = finrock("POST", "/dac/v1/transaction", body)
|
|
24
|
+
db.update_payout(payout_id, finrock_id=tx["id"], status=tx["status"])
|
|
25
|
+
return tx
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_terminal(status: str) -> bool:
|
|
29
|
+
return status in TERMINAL
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Prefer webhooks (`Withdraw` events carry your `sequence_id`) to update the payout status.
|
|
33
|
+
If you must poll, back off from 5 s to 60 s and stop at a terminal status.
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@finrock/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"mcpName": "io.finrock/mcp",
|
|
5
|
+
"description": "MCP server that helps AI agents integrate Finrock crypto payments",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -p tsconfig.build.json",
|
|
8
|
+
"prepublishOnly": "npm run build",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"start": "node dist/http.js",
|
|
12
|
+
"start:stdio": "node dist/stdio.js",
|
|
13
|
+
"dev": "tsx watch src/http.ts"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/gofinrock/mcp.git"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"finrock",
|
|
22
|
+
"crypto",
|
|
23
|
+
"payments"
|
|
24
|
+
],
|
|
25
|
+
"author": "Finrock",
|
|
26
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/gofinrock/mcp/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/gofinrock/mcp#readme",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
33
|
+
"express": "^5.3.0",
|
|
34
|
+
"zod": "^4.6.7"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/express": "^5.0.6",
|
|
38
|
+
"@types/node": "^26.6.5",
|
|
39
|
+
"tsx": "^4.23.15",
|
|
40
|
+
"typescript": "^7.0.2",
|
|
41
|
+
"vitest": "^5.0.5"
|
|
42
|
+
},
|
|
43
|
+
"type": "module",
|
|
44
|
+
"bin": {
|
|
45
|
+
"finrock-mcp": "dist/stdio.js"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"guides"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22"
|
|
53
|
+
},
|
|
54
|
+
"exports": {
|
|
55
|
+
".": "./dist/index.js"
|
|
56
|
+
}
|
|
57
|
+
}
|