@stablecoin.xyz/radius-mpp 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/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @stablecoin.xyz/radius-mpp
2
+
3
+ MPP custom payment method for **Radius blockchain** ERC-20 token transfers.
4
+
5
+ Built on the [mppx](https://github.com/wevm/mppx) SDK using the `Method.from` / `Method.toClient` / `Method.toServer` pattern.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @stablecoin.xyz/radius-mpp mppx viem
11
+ ```
12
+
13
+ ## Client usage
14
+
15
+ The client signs and broadcasts an ERC-20 `transfer()` on Radius, waits for confirmation, then returns the `txHash` as the credential payload. Gas price is estimated automatically via the chain's RPC (Radius uses legacy gas pricing, not EIP-1559).
16
+
17
+ ```ts
18
+ import { Mppx, radius } from '@stablecoin.xyz/radius-mpp/client'
19
+ import { radiusMainnet } from '@stablecoin.xyz/radius-mpp'
20
+ import { createWalletClient, http } from 'viem'
21
+ import { privateKeyToAccount } from 'viem/accounts'
22
+
23
+ const account = privateKeyToAccount('0x...')
24
+ const walletClient = createWalletClient({
25
+ account,
26
+ chain: radiusMainnet,
27
+ transport: http(radiusMainnet.rpcUrls.default.http[0]),
28
+ })
29
+
30
+ const { fetch } = Mppx.create({
31
+ methods: [radius({ walletClient })],
32
+ polyfill: false,
33
+ })
34
+
35
+ // Any 402 response from an MPP server using the "radius" method
36
+ // will be handled automatically
37
+ const response = await fetch('https://api.example.com/premium-endpoint')
38
+ ```
39
+
40
+ ## Server usage
41
+
42
+ The server verifies that the `txHash` corresponds to a successful ERC-20 transfer on Radius with the correct amount, token, and recipient. Import `Mppx` from the framework adapter you're using (`mppx/hono`, `mppx/express`, `mppx/nextjs`, or `mppx/elysia`).
43
+
44
+ ```ts
45
+ import { Mppx } from 'mppx/hono'
46
+ import { Expires } from 'mppx/server'
47
+ import { radius } from '@stablecoin.xyz/radius-mpp/server'
48
+ import { SBC_TOKEN, radiusMainnet } from '@stablecoin.xyz/radius-mpp'
49
+
50
+ const mppx = Mppx.create({
51
+ methods: [radius({ confirmations: 1 })],
52
+ })
53
+
54
+ // Hono example — gate a route behind a 1 SBC payment
55
+ app.get(
56
+ '/premium',
57
+ mppx.charge({
58
+ amount: '1000000', // 1 SBC (6 decimals)
59
+ currency: 'SBC',
60
+ chainId: radiusMainnet.id,
61
+ token: SBC_TOKEN.mainnet,
62
+ recipient: '0xYourAddress',
63
+ expires: Expires.minutes(5),
64
+ }),
65
+ (c) => c.json({ data: 'premium content' }),
66
+ )
67
+ ```
68
+
69
+ ### Server options
70
+
71
+ `radius()` accepts a config object passed to `Mppx.create`:
72
+
73
+ ```ts
74
+ const mppx = Mppx.create({
75
+ methods: [radius({
76
+ confirmations: 1, // optional
77
+ rpcUrl: 'https://...', // optional
78
+ })],
79
+ })
80
+ ```
81
+
82
+ | Option | Default | Description |
83
+ |--------|---------|-------------|
84
+ | `confirmations` | `1` | How many block confirmations to wait before accepting the payment. Radius has fast finality so `1` is usually sufficient. Increase for high-value transactions where you want extra certainty the tx won't reorg. |
85
+ | `rpcUrl` | Chain's default RPC | Override the RPC endpoint used to fetch and verify transaction receipts. Useful if you're running your own Radius node or need a higher-throughput RPC provider. |
86
+
87
+ Both are optional — `radius()` with no arguments works out of the box using chain defaults.
88
+
89
+ See [`examples/`](./examples) for complete working examples with Hono, Express, Next.js, and Elysia.
90
+
91
+ ## Supported chains
92
+
93
+ | Network | Chain ID | RPC | Explorer |
94
+ |---------|----------|-----|----------|
95
+ | Radius Mainnet | 723487 | `https://rpc.radiustech.xyz` | [network.radiustech.xyz](https://network.radiustech.xyz) |
96
+ | Radius Testnet | 72344 | `https://rpc.testnet.radiustech.xyz` | [testnet.radiustech.xyz](https://testnet.radiustech.xyz) |
97
+
98
+ Legacy chain ID `723` is aliased to mainnet for backwards compatibility.
99
+
100
+ ### Explorer helper
101
+
102
+ ```ts
103
+ import { explorerTxUrl } from '@stablecoin.xyz/radius-mpp'
104
+
105
+ const url = explorerTxUrl(72344, '0xee483...')
106
+ // → "https://testnet.radiustech.xyz/tx/0xee483..."
107
+ ```
108
+
109
+ ## Token
110
+
111
+ | Token | Address | Decimals |
112
+ |-------|---------|----------|
113
+ | SBC | `0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb` | 6 |
114
+
115
+ Same contract address on both mainnet and testnet.
116
+
117
+ ## Examples
118
+
119
+ Self-contained in `examples/` with their own `package.json`. To run:
120
+
121
+ ```bash
122
+ cd examples
123
+ pnpm install
124
+ MPP_SECRET_KEY=your-secret RECIPIENT=0xYourAddress pnpm hono
125
+ ```
126
+
127
+ | Script | Framework | Description |
128
+ |--------|-----------|-------------|
129
+ | `pnpm hono` | Hono | Single paid endpoint |
130
+ | `pnpm express` | Express | Express middleware |
131
+ | `pnpm testnet` | Hono | Testnet configuration |
132
+ | `pnpm multi-tier` | Hono | Multiple price tiers |
133
+ | `pnpm client` | — | Client auto-payment (`NETWORK=testnet` by default) |
134
+ | [`nextjs-route.ts`](./examples/nextjs-route.ts) | Next.js | App Router route handler (copy into your app) |
135
+ | [`elysia-server.ts`](./examples/elysia-server.ts) | Elysia | Bun/Elysia guard pattern |
136
+
137
+ ### Environment variables
138
+
139
+ | Variable | Used by | Description |
140
+ |----------|---------|-------------|
141
+ | `MPP_SECRET_KEY` | Server | HMAC signing key for challenges |
142
+ | `RECIPIENT` | Server | Wallet address to receive SBC payments |
143
+ | `PRIVATE_KEY` | Client | Hex-encoded wallet private key |
144
+ | `API_URL` | Client | Server URL (default: `http://localhost:3000`) |
145
+ | `NETWORK` | Client | `mainnet` or `testnet` (default: `testnet`) |
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ pnpm build # Build with tsup (ESM → dist/)
151
+ pnpm dev # Build in watch mode
152
+ pnpm typecheck # tsc --noEmit
153
+ pnpm test # Run all tests
154
+ pnpm test:coverage # Tests with coverage thresholds
155
+ pnpm test:unit # Unit tests only
156
+ pnpm test:integration # Integration tests only
157
+ ```
158
+
159
+ Pre-commit hook runs typecheck → build → tests with coverage enforcement. Installed automatically via `pnpm prepare`.
160
+
161
+ ### Coverage thresholds
162
+
163
+ | Metric | Threshold |
164
+ |--------|-----------|
165
+ | Lines | 90% |
166
+ | Branches | 85% |
167
+ | Functions | 90% |
168
+ | Statements | 90% |
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,100 @@
1
+ // src/index.ts
2
+ import { Method, z } from "mppx";
3
+
4
+ // src/constants.ts
5
+ import { defineChain } from "viem";
6
+ var SBC_TOKEN = {
7
+ mainnet: "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb",
8
+ testnet: "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb"
9
+ };
10
+ var SBC_DECIMALS = 6;
11
+ var radiusMainnet = defineChain({
12
+ id: 723487,
13
+ name: "Radius",
14
+ nativeCurrency: { name: "RUSD", symbol: "RUSD", decimals: 18 },
15
+ rpcUrls: {
16
+ default: { http: ["https://rpc.radiustech.xyz"] }
17
+ },
18
+ blockExplorers: {
19
+ default: { name: "Radius Explorer", url: "https://network.radiustech.xyz" }
20
+ }
21
+ });
22
+ var radiusTestnet = defineChain({
23
+ id: 72344,
24
+ name: "Radius Testnet",
25
+ nativeCurrency: { name: "RUSD", symbol: "RUSD", decimals: 18 },
26
+ rpcUrls: {
27
+ default: { http: ["https://rpc.testnet.radiustech.xyz"] }
28
+ },
29
+ blockExplorers: {
30
+ default: { name: "Radius Testnet Explorer", url: "https://testnet.radiustech.xyz" }
31
+ },
32
+ testnet: true
33
+ });
34
+ function resolveChain(chainId) {
35
+ if (chainId === radiusMainnet.id || chainId === 723) return radiusMainnet;
36
+ if (chainId === radiusTestnet.id) return radiusTestnet;
37
+ return null;
38
+ }
39
+ function explorerTxUrl(chainId, txHash) {
40
+ const chain = resolveChain(chainId);
41
+ if (!chain?.blockExplorers?.default) return null;
42
+ return `${chain.blockExplorers.default.url}/tx/${txHash}`;
43
+ }
44
+ var ERC20_ABI = [
45
+ {
46
+ inputs: [
47
+ { name: "to", type: "address" },
48
+ { name: "amount", type: "uint256" }
49
+ ],
50
+ name: "transfer",
51
+ outputs: [{ name: "", type: "bool" }],
52
+ stateMutability: "nonpayable",
53
+ type: "function"
54
+ },
55
+ {
56
+ inputs: [{ name: "account", type: "address" }],
57
+ name: "balanceOf",
58
+ outputs: [{ name: "", type: "uint256" }],
59
+ stateMutability: "view",
60
+ type: "function"
61
+ }
62
+ ];
63
+
64
+ // src/index.ts
65
+ var charge = Method.from({
66
+ intent: "charge",
67
+ name: "radius",
68
+ schema: {
69
+ credential: {
70
+ payload: z.object({
71
+ /** The on-chain transaction hash of the ERC-20 transfer */
72
+ txHash: z.string()
73
+ })
74
+ },
75
+ request: z.object({
76
+ /** Transfer amount in atomic units (e.g. "1000000" for 1 SBC with 6 decimals) */
77
+ amount: z.string(),
78
+ /** Token symbol or identifier (e.g. "SBC") */
79
+ currency: z.string(),
80
+ /** Chain ID (723487 for mainnet, 72344 for testnet) */
81
+ chainId: z.number(),
82
+ /** ERC-20 token contract address on Radius */
83
+ token: z.string(),
84
+ /** Recipient address (the seller / payee) */
85
+ recipient: z.string()
86
+ })
87
+ }
88
+ });
89
+
90
+ export {
91
+ SBC_TOKEN,
92
+ SBC_DECIMALS,
93
+ radiusMainnet,
94
+ radiusTestnet,
95
+ resolveChain,
96
+ explorerTxUrl,
97
+ ERC20_ABI,
98
+ charge
99
+ };
100
+ //# sourceMappingURL=chunk-TM4JSIP6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/constants.ts"],"sourcesContent":["/**\n * @stablecoin.xyz/radius-mpp\n *\n * Shared method definition for Radius ERC-20 payments via MPP.\n * Import from the root path in code that needs the schema but not\n * client or server logic (e.g. shared types).\n */\n\nimport { Method, z } from 'mppx'\n\nexport { radiusMainnet, radiusTestnet, SBC_TOKEN, SBC_DECIMALS, explorerTxUrl } from './constants.js'\n\n/**\n * Radius charge method — one-time ERC-20 token transfer on Radius.\n *\n * Request schema: the Challenge fields the server sends to the client.\n * Credential payload: what the client returns after broadcasting the tx.\n */\nexport const charge = Method.from({\n intent: 'charge',\n name: 'radius',\n schema: {\n credential: {\n payload: z.object({\n /** The on-chain transaction hash of the ERC-20 transfer */\n txHash: z.string(),\n }),\n },\n request: z.object({\n /** Transfer amount in atomic units (e.g. \"1000000\" for 1 SBC with 6 decimals) */\n amount: z.string(),\n /** Token symbol or identifier (e.g. \"SBC\") */\n currency: z.string(),\n /** Chain ID (723487 for mainnet, 72344 for testnet) */\n chainId: z.number(),\n /** ERC-20 token contract address on Radius */\n token: z.string(),\n /** Recipient address (the seller / payee) */\n recipient: z.string(),\n }),\n },\n})\n\n/** Re-export the inferred types for external consumers */\nexport type RadiusCharge = typeof charge\n","import { defineChain } from 'viem'\n\n// ---------------------------------------------------------------------------\n// Token addresses (SBC stablecoin on Radius)\n// ---------------------------------------------------------------------------\n\nexport const SBC_TOKEN = {\n mainnet: '0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb' as const,\n testnet: '0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb' as const,\n}\n\nexport const SBC_DECIMALS = 6\n\n// ---------------------------------------------------------------------------\n// Chain definitions (viem-compatible)\n// ---------------------------------------------------------------------------\n\nexport const radiusMainnet = defineChain({\n id: 723487,\n name: 'Radius',\n nativeCurrency: { name: 'RUSD', symbol: 'RUSD', decimals: 18 },\n rpcUrls: {\n default: { http: ['https://rpc.radiustech.xyz'] },\n },\n blockExplorers: {\n default: { name: 'Radius Explorer', url: 'https://network.radiustech.xyz' },\n },\n})\n\nexport const radiusTestnet = defineChain({\n id: 72344,\n name: 'Radius Testnet',\n nativeCurrency: { name: 'RUSD', symbol: 'RUSD', decimals: 18 },\n rpcUrls: {\n default: { http: ['https://rpc.testnet.radiustech.xyz'] },\n },\n blockExplorers: {\n default: { name: 'Radius Testnet Explorer', url: 'https://testnet.radiustech.xyz' },\n },\n testnet: true,\n})\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Resolve chain definition by chain ID. Returns mainnet for 723 (legacy alias). */\nexport function resolveChain(chainId: number) {\n if (chainId === radiusMainnet.id || chainId === 723) return radiusMainnet\n if (chainId === radiusTestnet.id) return radiusTestnet\n return null\n}\n\n/** Resolve the SBC token address for a given chain ID. */\nexport function resolveTokenAddress(chainId: number) {\n if (chainId === radiusMainnet.id || chainId === 723) return SBC_TOKEN.mainnet\n if (chainId === radiusTestnet.id) return SBC_TOKEN.testnet\n return null\n}\n\n/** Build an explorer URL for a transaction hash on a given chain. */\nexport function explorerTxUrl(chainId: number, txHash: string): string | null {\n const chain = resolveChain(chainId)\n if (!chain?.blockExplorers?.default) return null\n return `${chain.blockExplorers.default.url}/tx/${txHash}`\n}\n\n// Standard ERC-20 ABI subset used by client and server\nexport const ERC20_ABI = [\n {\n inputs: [\n { name: 'to', type: 'address' },\n { name: 'amount', type: 'uint256' },\n ],\n name: 'transfer',\n outputs: [{ name: '', type: 'bool' }],\n stateMutability: 'nonpayable',\n type: 'function',\n },\n {\n inputs: [{ name: 'account', type: 'address' }],\n name: 'balanceOf',\n outputs: [{ name: '', type: 'uint256' }],\n stateMutability: 'view',\n type: 'function',\n },\n] as const\n"],"mappings":";AAQA,SAAS,QAAQ,SAAS;;;ACR1B,SAAS,mBAAmB;AAMrB,IAAM,YAAY;AAAA,EACvB,SAAS;AAAA,EACT,SAAS;AACX;AAEO,IAAM,eAAe;AAMrB,IAAM,gBAAgB,YAAY;AAAA,EACvC,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,gBAAgB,EAAE,MAAM,QAAQ,QAAQ,QAAQ,UAAU,GAAG;AAAA,EAC7D,SAAS;AAAA,IACP,SAAS,EAAE,MAAM,CAAC,4BAA4B,EAAE;AAAA,EAClD;AAAA,EACA,gBAAgB;AAAA,IACd,SAAS,EAAE,MAAM,mBAAmB,KAAK,iCAAiC;AAAA,EAC5E;AACF,CAAC;AAEM,IAAM,gBAAgB,YAAY;AAAA,EACvC,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,gBAAgB,EAAE,MAAM,QAAQ,QAAQ,QAAQ,UAAU,GAAG;AAAA,EAC7D,SAAS;AAAA,IACP,SAAS,EAAE,MAAM,CAAC,oCAAoC,EAAE;AAAA,EAC1D;AAAA,EACA,gBAAgB;AAAA,IACd,SAAS,EAAE,MAAM,2BAA2B,KAAK,iCAAiC;AAAA,EACpF;AAAA,EACA,SAAS;AACX,CAAC;AAOM,SAAS,aAAa,SAAiB;AAC5C,MAAI,YAAY,cAAc,MAAM,YAAY,IAAK,QAAO;AAC5D,MAAI,YAAY,cAAc,GAAI,QAAO;AACzC,SAAO;AACT;AAUO,SAAS,cAAc,SAAiB,QAA+B;AAC5E,QAAM,QAAQ,aAAa,OAAO;AAClC,MAAI,CAAC,OAAO,gBAAgB,QAAS,QAAO;AAC5C,SAAO,GAAG,MAAM,eAAe,QAAQ,GAAG,OAAO,MAAM;AACzD;AAGO,IAAM,YAAY;AAAA,EACvB;AAAA,IACE,QAAQ;AAAA,MACN,EAAE,MAAM,MAAM,MAAM,UAAU;AAAA,MAC9B,EAAE,MAAM,UAAU,MAAM,UAAU;AAAA,IACpC;AAAA,IACA,MAAM;AAAA,IACN,SAAS,CAAC,EAAE,MAAM,IAAI,MAAM,OAAO,CAAC;AAAA,IACpC,iBAAiB;AAAA,IACjB,MAAM;AAAA,EACR;AAAA,EACA;AAAA,IACE,QAAQ,CAAC,EAAE,MAAM,WAAW,MAAM,UAAU,CAAC;AAAA,IAC7C,MAAM;AAAA,IACN,SAAS,CAAC,EAAE,MAAM,IAAI,MAAM,UAAU,CAAC;AAAA,IACvC,iBAAiB;AAAA,IACjB,MAAM;AAAA,EACR;AACF;;;ADpEO,IAAM,SAAS,OAAO,KAAK;AAAA,EAChC,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,QAAQ;AAAA,IACN,YAAY;AAAA,MACV,SAAS,EAAE,OAAO;AAAA;AAAA,QAEhB,QAAQ,EAAE,OAAO;AAAA,MACnB,CAAC;AAAA,IACH;AAAA,IACA,SAAS,EAAE,OAAO;AAAA;AAAA,MAEhB,QAAQ,EAAE,OAAO;AAAA;AAAA,MAEjB,UAAU,EAAE,OAAO;AAAA;AAAA,MAEnB,SAAS,EAAE,OAAO;AAAA;AAAA,MAElB,OAAO,EAAE,OAAO;AAAA;AAAA,MAEhB,WAAW,EAAE,OAAO;AAAA,IACtB,CAAC;AAAA,EACH;AACF,CAAC;","names":[]}
@@ -0,0 +1,22 @@
1
+ import { Method } from 'mppx';
2
+ export { Mppx } from 'mppx/client';
3
+ import { WalletClient, Transport, Chain, Account } from 'viem';
4
+ import { RadiusCharge } from './index.js';
5
+
6
+ /**
7
+ * @stablecoin.xyz/radius-mpp/client
8
+ *
9
+ * Client-side: signs and broadcasts an ERC-20 transfer on Radius,
10
+ * then returns the txHash as the Credential payload.
11
+ */
12
+
13
+ interface RadiusClientConfig {
14
+ /**
15
+ * A viem WalletClient that can sign and send transactions on Radius.
16
+ * Must have an account attached (e.g. via privateKeyToAccount or browser wallet).
17
+ */
18
+ walletClient: WalletClient<Transport, Chain, Account>;
19
+ }
20
+ declare function radius(config: RadiusClientConfig): Method.Client<RadiusCharge>;
21
+
22
+ export { type RadiusClientConfig, radius };
package/dist/client.js ADDED
@@ -0,0 +1,55 @@
1
+ import {
2
+ ERC20_ABI,
3
+ charge,
4
+ resolveChain
5
+ } from "./chunk-TM4JSIP6.js";
6
+
7
+ // src/client.ts
8
+ import { Credential, Method } from "mppx";
9
+ import { Mppx } from "mppx/client";
10
+ import {
11
+ createPublicClient,
12
+ http,
13
+ encodeFunctionData
14
+ } from "viem";
15
+ function radius(config) {
16
+ const { walletClient } = config;
17
+ return Method.toClient(charge, {
18
+ async createCredential({ challenge }) {
19
+ const { amount, chainId, token, recipient } = challenge.request;
20
+ const chain = resolveChain(chainId);
21
+ if (!chain) {
22
+ throw new Error(`Unsupported Radius chain ID: ${chainId}`);
23
+ }
24
+ const data = encodeFunctionData({
25
+ abi: ERC20_ABI,
26
+ functionName: "transfer",
27
+ args: [recipient, BigInt(amount)]
28
+ });
29
+ const publicClient = createPublicClient({
30
+ chain,
31
+ transport: http(chain.rpcUrls.default.http[0])
32
+ });
33
+ const gasPrice = await publicClient.getGasPrice();
34
+ const txHash = await walletClient.sendTransaction({
35
+ to: token,
36
+ data,
37
+ chain,
38
+ gasPrice
39
+ });
40
+ await publicClient.waitForTransactionReceipt({
41
+ hash: txHash,
42
+ confirmations: 1
43
+ });
44
+ return Credential.serialize({
45
+ challenge,
46
+ payload: { txHash }
47
+ });
48
+ }
49
+ });
50
+ }
51
+ export {
52
+ Mppx,
53
+ radius
54
+ };
55
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"sourcesContent":["/**\n * @stablecoin.xyz/radius-mpp/client\n *\n * Client-side: signs and broadcasts an ERC-20 transfer on Radius,\n * then returns the txHash as the Credential payload.\n */\n\nimport { Credential, Method } from 'mppx'\nimport { Mppx } from 'mppx/client'\nimport {\n createPublicClient,\n createWalletClient,\n http,\n type Account,\n type Chain,\n type Transport,\n type WalletClient,\n encodeFunctionData,\n} from 'viem'\nimport { charge, type RadiusCharge } from './index.js'\nimport { ERC20_ABI, resolveChain } from './constants.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface RadiusClientConfig {\n /**\n * A viem WalletClient that can sign and send transactions on Radius.\n * Must have an account attached (e.g. via privateKeyToAccount or browser wallet).\n */\n walletClient: WalletClient<Transport, Chain, Account>\n}\n\n// ---------------------------------------------------------------------------\n// Client method factory\n// ---------------------------------------------------------------------------\n\nexport function radius(config: RadiusClientConfig): Method.Client<RadiusCharge> {\n const { walletClient } = config\n\n return Method.toClient(charge, {\n async createCredential({ challenge }) {\n const { amount, chainId, token, recipient } = challenge.request\n\n // Resolve the Radius chain definition for RPC access\n const chain = resolveChain(chainId)\n if (!chain) {\n throw new Error(`Unsupported Radius chain ID: ${chainId}`)\n }\n\n // Build the ERC-20 transfer calldata\n const data = encodeFunctionData({\n abi: ERC20_ABI,\n functionName: 'transfer',\n args: [recipient as `0x${string}`, BigInt(amount)],\n })\n\n // Use the chain's RPC for gas estimation and receipt waiting\n const publicClient = createPublicClient({\n chain,\n transport: http(chain.rpcUrls.default.http[0]),\n })\n\n // Estimate gas price (Radius doesn't support EIP-1559 fee estimation)\n const gasPrice = await publicClient.getGasPrice()\n\n // Send the transaction using the resolved chain's RPC\n const txHash = await walletClient.sendTransaction({\n to: token as `0x${string}`,\n data,\n chain,\n gasPrice,\n })\n\n // TODO: Make confirmation count configurable (Radius has fast blocks,\n // 1 confirmation should suffice but we may want to allow tuning)\n await publicClient.waitForTransactionReceipt({\n hash: txHash,\n confirmations: 1,\n })\n\n return Credential.serialize({\n challenge,\n payload: { txHash },\n })\n },\n })\n}\n\n// Re-export Mppx so consumers only need one import\nexport { Mppx }\n"],"mappings":";;;;;;;AAOA,SAAS,YAAY,cAAc;AACnC,SAAS,YAAY;AACrB;AAAA,EACE;AAAA,EAEA;AAAA,EAKA;AAAA,OACK;AAoBA,SAAS,OAAO,QAAyD;AAC9E,QAAM,EAAE,aAAa,IAAI;AAEzB,SAAO,OAAO,SAAS,QAAQ;AAAA,IAC7B,MAAM,iBAAiB,EAAE,UAAU,GAAG;AACpC,YAAM,EAAE,QAAQ,SAAS,OAAO,UAAU,IAAI,UAAU;AAGxD,YAAM,QAAQ,aAAa,OAAO;AAClC,UAAI,CAAC,OAAO;AACV,cAAM,IAAI,MAAM,gCAAgC,OAAO,EAAE;AAAA,MAC3D;AAGA,YAAM,OAAO,mBAAmB;AAAA,QAC9B,KAAK;AAAA,QACL,cAAc;AAAA,QACd,MAAM,CAAC,WAA4B,OAAO,MAAM,CAAC;AAAA,MACnD,CAAC;AAGD,YAAM,eAAe,mBAAmB;AAAA,QACtC;AAAA,QACA,WAAW,KAAK,MAAM,QAAQ,QAAQ,KAAK,CAAC,CAAC;AAAA,MAC/C,CAAC;AAGD,YAAM,WAAW,MAAM,aAAa,YAAY;AAGhD,YAAM,SAAS,MAAM,aAAa,gBAAgB;AAAA,QAChD,IAAI;AAAA,QACJ;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAID,YAAM,aAAa,0BAA0B;AAAA,QAC3C,MAAM;AAAA,QACN,eAAe;AAAA,MACjB,CAAC;AAED,aAAO,WAAW,UAAU;AAAA,QAC1B;AAAA,QACA,SAAS,EAAE,OAAO;AAAA,MACpB,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -0,0 +1,147 @@
1
+ import { z } from 'mppx';
2
+ import * as viem from 'viem';
3
+
4
+ declare const SBC_TOKEN: {
5
+ mainnet: "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb";
6
+ testnet: "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb";
7
+ };
8
+ declare const SBC_DECIMALS = 6;
9
+ declare const radiusMainnet: {
10
+ blockExplorers: {
11
+ readonly default: {
12
+ readonly name: "Radius Explorer";
13
+ readonly url: "https://network.radiustech.xyz";
14
+ };
15
+ };
16
+ blockTime?: number | undefined | undefined;
17
+ contracts?: {
18
+ [x: string]: viem.ChainContract | {
19
+ [sourceId: number]: viem.ChainContract | undefined;
20
+ } | undefined;
21
+ ensRegistry?: viem.ChainContract | undefined;
22
+ ensUniversalResolver?: viem.ChainContract | undefined;
23
+ multicall3?: viem.ChainContract | undefined;
24
+ erc6492Verifier?: viem.ChainContract | undefined;
25
+ } | undefined;
26
+ ensTlds?: readonly string[] | undefined;
27
+ id: 723487;
28
+ name: "Radius";
29
+ nativeCurrency: {
30
+ readonly name: "RUSD";
31
+ readonly symbol: "RUSD";
32
+ readonly decimals: 18;
33
+ };
34
+ experimental_preconfirmationTime?: number | undefined | undefined;
35
+ rpcUrls: {
36
+ readonly default: {
37
+ readonly http: readonly ["https://rpc.radiustech.xyz"];
38
+ };
39
+ };
40
+ sourceId?: number | undefined | undefined;
41
+ testnet?: boolean | undefined | undefined;
42
+ custom?: Record<string, unknown> | undefined;
43
+ extendSchema?: Record<string, unknown> | undefined;
44
+ fees?: viem.ChainFees<undefined> | undefined;
45
+ formatters?: undefined;
46
+ prepareTransactionRequest?: ((args: viem.PrepareTransactionRequestParameters, options: {
47
+ phase: "beforeFillTransaction" | "beforeFillParameters" | "afterFillParameters";
48
+ }) => Promise<viem.PrepareTransactionRequestParameters>) | [fn: ((args: viem.PrepareTransactionRequestParameters, options: {
49
+ phase: "beforeFillTransaction" | "beforeFillParameters" | "afterFillParameters";
50
+ }) => Promise<viem.PrepareTransactionRequestParameters>) | undefined, options: {
51
+ runAt: readonly ("beforeFillTransaction" | "beforeFillParameters" | "afterFillParameters")[];
52
+ }] | undefined;
53
+ serializers?: viem.ChainSerializers<undefined, viem.TransactionSerializable> | undefined;
54
+ verifyHash?: ((client: viem.Client, parameters: viem.VerifyHashActionParameters) => Promise<viem.VerifyHashActionReturnType>) | undefined;
55
+ };
56
+ declare const radiusTestnet: {
57
+ blockExplorers: {
58
+ readonly default: {
59
+ readonly name: "Radius Testnet Explorer";
60
+ readonly url: "https://testnet.radiustech.xyz";
61
+ };
62
+ };
63
+ blockTime?: number | undefined | undefined;
64
+ contracts?: {
65
+ [x: string]: viem.ChainContract | {
66
+ [sourceId: number]: viem.ChainContract | undefined;
67
+ } | undefined;
68
+ ensRegistry?: viem.ChainContract | undefined;
69
+ ensUniversalResolver?: viem.ChainContract | undefined;
70
+ multicall3?: viem.ChainContract | undefined;
71
+ erc6492Verifier?: viem.ChainContract | undefined;
72
+ } | undefined;
73
+ ensTlds?: readonly string[] | undefined;
74
+ id: 72344;
75
+ name: "Radius Testnet";
76
+ nativeCurrency: {
77
+ readonly name: "RUSD";
78
+ readonly symbol: "RUSD";
79
+ readonly decimals: 18;
80
+ };
81
+ experimental_preconfirmationTime?: number | undefined | undefined;
82
+ rpcUrls: {
83
+ readonly default: {
84
+ readonly http: readonly ["https://rpc.testnet.radiustech.xyz"];
85
+ };
86
+ };
87
+ sourceId?: number | undefined | undefined;
88
+ testnet: true;
89
+ custom?: Record<string, unknown> | undefined;
90
+ extendSchema?: Record<string, unknown> | undefined;
91
+ fees?: viem.ChainFees<undefined> | undefined;
92
+ formatters?: undefined;
93
+ prepareTransactionRequest?: ((args: viem.PrepareTransactionRequestParameters, options: {
94
+ phase: "beforeFillTransaction" | "beforeFillParameters" | "afterFillParameters";
95
+ }) => Promise<viem.PrepareTransactionRequestParameters>) | [fn: ((args: viem.PrepareTransactionRequestParameters, options: {
96
+ phase: "beforeFillTransaction" | "beforeFillParameters" | "afterFillParameters";
97
+ }) => Promise<viem.PrepareTransactionRequestParameters>) | undefined, options: {
98
+ runAt: readonly ("beforeFillTransaction" | "beforeFillParameters" | "afterFillParameters")[];
99
+ }] | undefined;
100
+ serializers?: viem.ChainSerializers<undefined, viem.TransactionSerializable> | undefined;
101
+ verifyHash?: ((client: viem.Client, parameters: viem.VerifyHashActionParameters) => Promise<viem.VerifyHashActionReturnType>) | undefined;
102
+ };
103
+ /** Build an explorer URL for a transaction hash on a given chain. */
104
+ declare function explorerTxUrl(chainId: number, txHash: string): string | null;
105
+
106
+ /**
107
+ * @stablecoin.xyz/radius-mpp
108
+ *
109
+ * Shared method definition for Radius ERC-20 payments via MPP.
110
+ * Import from the root path in code that needs the schema but not
111
+ * client or server logic (e.g. shared types).
112
+ */
113
+
114
+ /**
115
+ * Radius charge method — one-time ERC-20 token transfer on Radius.
116
+ *
117
+ * Request schema: the Challenge fields the server sends to the client.
118
+ * Credential payload: what the client returns after broadcasting the tx.
119
+ */
120
+ declare const charge: {
121
+ readonly intent: "charge";
122
+ readonly name: "radius";
123
+ readonly schema: {
124
+ readonly credential: {
125
+ readonly payload: z.ZodMiniObject<{
126
+ /** The on-chain transaction hash of the ERC-20 transfer */
127
+ txHash: z.ZodMiniString<string>;
128
+ }, z.core.$strip>;
129
+ };
130
+ readonly request: z.ZodMiniObject<{
131
+ /** Transfer amount in atomic units (e.g. "1000000" for 1 SBC with 6 decimals) */
132
+ amount: z.ZodMiniString<string>;
133
+ /** Token symbol or identifier (e.g. "SBC") */
134
+ currency: z.ZodMiniString<string>;
135
+ /** Chain ID (723487 for mainnet, 72344 for testnet) */
136
+ chainId: z.ZodMiniNumber<number>;
137
+ /** ERC-20 token contract address on Radius */
138
+ token: z.ZodMiniString<string>;
139
+ /** Recipient address (the seller / payee) */
140
+ recipient: z.ZodMiniString<string>;
141
+ }, z.core.$strip>;
142
+ };
143
+ };
144
+ /** Re-export the inferred types for external consumers */
145
+ type RadiusCharge = typeof charge;
146
+
147
+ export { type RadiusCharge, SBC_DECIMALS, SBC_TOKEN, charge, explorerTxUrl, radiusMainnet, radiusTestnet };
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import {
2
+ SBC_DECIMALS,
3
+ SBC_TOKEN,
4
+ charge,
5
+ explorerTxUrl,
6
+ radiusMainnet,
7
+ radiusTestnet
8
+ } from "./chunk-TM4JSIP6.js";
9
+ export {
10
+ SBC_DECIMALS,
11
+ SBC_TOKEN,
12
+ charge,
13
+ explorerTxUrl,
14
+ radiusMainnet,
15
+ radiusTestnet
16
+ };
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,26 @@
1
+ import { Method } from 'mppx';
2
+ export { Expires, Mppx, Store } from 'mppx/server';
3
+ import { RadiusCharge } from './index.js';
4
+ import 'viem';
5
+
6
+ /**
7
+ * @stablecoin.xyz/radius-mpp/server
8
+ *
9
+ * Server-side: verifies that an ERC-20 transfer actually landed on Radius
10
+ * with the correct amount, recipient, and token.
11
+ */
12
+
13
+ interface RadiusServerConfig {
14
+ /**
15
+ * Optional custom RPC URL. If not provided, uses the default from chain config.
16
+ */
17
+ rpcUrl?: string;
18
+ /**
19
+ * Number of confirmations to require before accepting the tx.
20
+ * Defaults to 1 (Radius has fast finality).
21
+ */
22
+ confirmations?: number;
23
+ }
24
+ declare function radius(config?: RadiusServerConfig): Method.Server<RadiusCharge>;
25
+
26
+ export { type RadiusServerConfig, radius };
package/dist/server.js ADDED
@@ -0,0 +1,104 @@
1
+ import {
2
+ charge,
3
+ resolveChain
4
+ } from "./chunk-TM4JSIP6.js";
5
+
6
+ // src/server.ts
7
+ import { Method, Receipt } from "mppx";
8
+ import { Mppx, Expires, Store } from "mppx/server";
9
+ import {
10
+ createPublicClient,
11
+ http,
12
+ decodeEventLog
13
+ } from "viem";
14
+ var TRANSFER_EVENT_ABI = [
15
+ {
16
+ type: "event",
17
+ name: "Transfer",
18
+ inputs: [
19
+ { indexed: true, name: "from", type: "address" },
20
+ { indexed: true, name: "to", type: "address" },
21
+ { indexed: false, name: "value", type: "uint256" }
22
+ ]
23
+ }
24
+ ];
25
+ function radius(config = {}) {
26
+ const { confirmations = 1 } = config;
27
+ return Method.toServer(charge, {
28
+ async verify({ credential }) {
29
+ const { txHash } = credential.payload;
30
+ const { amount, chainId, token, recipient } = credential.challenge.request;
31
+ const chain = resolveChain(chainId);
32
+ if (!chain) {
33
+ throw new Error(`Unsupported Radius chain ID: ${chainId}`);
34
+ }
35
+ const rpcUrl = config.rpcUrl ?? chain.rpcUrls.default.http[0];
36
+ const publicClient = createPublicClient({
37
+ chain,
38
+ transport: http(rpcUrl)
39
+ });
40
+ const receipt = await publicClient.getTransactionReceipt({
41
+ hash: txHash
42
+ });
43
+ if (receipt.status !== "success") {
44
+ throw new Error(`Transaction ${txHash} failed on-chain (status: ${receipt.status})`);
45
+ }
46
+ const txToAddress = receipt.to?.toLowerCase();
47
+ if (txToAddress !== token.toLowerCase()) {
48
+ throw new Error(
49
+ `Transaction target ${txToAddress} does not match expected token ${token}`
50
+ );
51
+ }
52
+ const transferLog = findMatchingTransferLog(
53
+ receipt.logs,
54
+ token,
55
+ recipient,
56
+ BigInt(amount)
57
+ );
58
+ if (!transferLog) {
59
+ throw new Error(
60
+ `No matching ERC-20 Transfer event found for recipient ${recipient} with amount >= ${amount}`
61
+ );
62
+ }
63
+ if (confirmations > 1) {
64
+ await publicClient.waitForTransactionReceipt({
65
+ hash: txHash,
66
+ confirmations
67
+ });
68
+ }
69
+ return Receipt.from({
70
+ method: "radius",
71
+ reference: txHash,
72
+ status: "success",
73
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
74
+ });
75
+ }
76
+ });
77
+ }
78
+ function findMatchingTransferLog(logs, token, recipient, minAmount) {
79
+ for (const log of logs) {
80
+ if (log.address.toLowerCase() !== token.toLowerCase()) continue;
81
+ try {
82
+ const decoded = decodeEventLog({
83
+ abi: TRANSFER_EVENT_ABI,
84
+ data: log.data,
85
+ topics: log.topics
86
+ });
87
+ if (decoded.eventName !== "Transfer") continue;
88
+ const { to, value } = decoded.args;
89
+ if (to.toLowerCase() === recipient.toLowerCase() && value >= minAmount) {
90
+ return { from: decoded.args.from, to, value };
91
+ }
92
+ } catch {
93
+ continue;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ export {
99
+ Expires,
100
+ Mppx,
101
+ Store,
102
+ radius
103
+ };
104
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * @stablecoin.xyz/radius-mpp/server\n *\n * Server-side: verifies that an ERC-20 transfer actually landed on Radius\n * with the correct amount, recipient, and token.\n */\n\nimport { Method, Receipt } from 'mppx'\nimport { Mppx, Expires, Store } from 'mppx/server'\nimport {\n createPublicClient,\n http,\n decodeEventLog,\n type Log,\n} from 'viem'\nimport { charge, type RadiusCharge } from './index.js'\nimport { resolveChain, SBC_DECIMALS } from './constants.js'\n\n// ---------------------------------------------------------------------------\n// ERC-20 Transfer event ABI (for log decoding)\n// ---------------------------------------------------------------------------\n\nconst TRANSFER_EVENT_ABI = [\n {\n type: 'event',\n name: 'Transfer',\n inputs: [\n { indexed: true, name: 'from', type: 'address' },\n { indexed: true, name: 'to', type: 'address' },\n { indexed: false, name: 'value', type: 'uint256' },\n ],\n },\n] as const\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface RadiusServerConfig {\n /**\n * Optional custom RPC URL. If not provided, uses the default from chain config.\n */\n rpcUrl?: string\n\n /**\n * Number of confirmations to require before accepting the tx.\n * Defaults to 1 (Radius has fast finality).\n */\n confirmations?: number\n}\n\n// ---------------------------------------------------------------------------\n// Server method factory\n// ---------------------------------------------------------------------------\n\nexport function radius(config: RadiusServerConfig = {}): Method.Server<RadiusCharge> {\n const { confirmations = 1 } = config\n\n return Method.toServer(charge, {\n async verify({ credential }) {\n const { txHash } = credential.payload\n const { amount, chainId, token, recipient } = credential.challenge.request\n\n // Resolve chain\n const chain = resolveChain(chainId)\n if (!chain) {\n throw new Error(`Unsupported Radius chain ID: ${chainId}`)\n }\n\n const rpcUrl = config.rpcUrl ?? chain.rpcUrls.default.http[0]\n const publicClient = createPublicClient({\n chain,\n transport: http(rpcUrl),\n })\n\n // Fetch the transaction receipt\n const receipt = await publicClient.getTransactionReceipt({\n hash: txHash as `0x${string}`,\n })\n\n // Check tx succeeded\n if (receipt.status !== 'success') {\n throw new Error(`Transaction ${txHash} failed on-chain (status: ${receipt.status})`)\n }\n\n // Check the tx was sent to the correct token contract\n // TODO: Also verify receipt.from if we want to enforce payer identity\n const txToAddress = receipt.to?.toLowerCase()\n if (txToAddress !== token.toLowerCase()) {\n throw new Error(\n `Transaction target ${txToAddress} does not match expected token ${token}`\n )\n }\n\n // Find the Transfer event log that matches recipient and amount\n const transferLog = findMatchingTransferLog(\n receipt.logs,\n token,\n recipient,\n BigInt(amount),\n )\n\n if (!transferLog) {\n throw new Error(\n `No matching ERC-20 Transfer event found for recipient ${recipient} with amount >= ${amount}`\n )\n }\n\n // Optionally wait for additional confirmations\n if (confirmations > 1) {\n await publicClient.waitForTransactionReceipt({\n hash: txHash as `0x${string}`,\n confirmations,\n })\n }\n\n return Receipt.from({\n method: 'radius',\n reference: txHash,\n status: 'success',\n timestamp: new Date().toISOString(),\n })\n },\n })\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction findMatchingTransferLog(\n logs: Log[],\n token: string,\n recipient: string,\n minAmount: bigint,\n) {\n for (const log of logs) {\n // Only look at logs from the correct token contract\n if (log.address.toLowerCase() !== token.toLowerCase()) continue\n\n try {\n const decoded = decodeEventLog({\n abi: TRANSFER_EVENT_ABI,\n data: log.data,\n topics: log.topics,\n })\n\n if (decoded.eventName !== 'Transfer') continue\n\n const { to, value } = decoded.args\n if (\n to.toLowerCase() === recipient.toLowerCase() &&\n value >= minAmount\n ) {\n return { from: decoded.args.from, to, value }\n }\n } catch {\n // Not a Transfer event from this ABI, skip\n continue\n }\n }\n\n return null\n}\n\n// Re-export Mppx, Expires, Store so consumers only need one import\nexport { Mppx, Expires, Store }\n"],"mappings":";;;;;;AAOA,SAAS,QAAQ,eAAe;AAChC,SAAS,MAAM,SAAS,aAAa;AACrC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAQP,IAAM,qBAAqB;AAAA,EACzB;AAAA,IACE,MAAM;AAAA,IACN,MAAM;AAAA,IACN,QAAQ;AAAA,MACN,EAAE,SAAS,MAAM,MAAM,QAAQ,MAAM,UAAU;AAAA,MAC/C,EAAE,SAAS,MAAM,MAAM,MAAM,MAAM,UAAU;AAAA,MAC7C,EAAE,SAAS,OAAO,MAAM,SAAS,MAAM,UAAU;AAAA,IACnD;AAAA,EACF;AACF;AAuBO,SAAS,OAAO,SAA6B,CAAC,GAAgC;AACnF,QAAM,EAAE,gBAAgB,EAAE,IAAI;AAE9B,SAAO,OAAO,SAAS,QAAQ;AAAA,IAC7B,MAAM,OAAO,EAAE,WAAW,GAAG;AAC3B,YAAM,EAAE,OAAO,IAAI,WAAW;AAC9B,YAAM,EAAE,QAAQ,SAAS,OAAO,UAAU,IAAI,WAAW,UAAU;AAGnE,YAAM,QAAQ,aAAa,OAAO;AAClC,UAAI,CAAC,OAAO;AACV,cAAM,IAAI,MAAM,gCAAgC,OAAO,EAAE;AAAA,MAC3D;AAEA,YAAM,SAAS,OAAO,UAAU,MAAM,QAAQ,QAAQ,KAAK,CAAC;AAC5D,YAAM,eAAe,mBAAmB;AAAA,QACtC;AAAA,QACA,WAAW,KAAK,MAAM;AAAA,MACxB,CAAC;AAGD,YAAM,UAAU,MAAM,aAAa,sBAAsB;AAAA,QACvD,MAAM;AAAA,MACR,CAAC;AAGD,UAAI,QAAQ,WAAW,WAAW;AAChC,cAAM,IAAI,MAAM,eAAe,MAAM,6BAA6B,QAAQ,MAAM,GAAG;AAAA,MACrF;AAIA,YAAM,cAAc,QAAQ,IAAI,YAAY;AAC5C,UAAI,gBAAgB,MAAM,YAAY,GAAG;AACvC,cAAM,IAAI;AAAA,UACR,sBAAsB,WAAW,kCAAkC,KAAK;AAAA,QAC1E;AAAA,MACF;AAGA,YAAM,cAAc;AAAA,QAClB,QAAQ;AAAA,QACR;AAAA,QACA;AAAA,QACA,OAAO,MAAM;AAAA,MACf;AAEA,UAAI,CAAC,aAAa;AAChB,cAAM,IAAI;AAAA,UACR,yDAAyD,SAAS,mBAAmB,MAAM;AAAA,QAC7F;AAAA,MACF;AAGA,UAAI,gBAAgB,GAAG;AACrB,cAAM,aAAa,0BAA0B;AAAA,UAC3C,MAAM;AAAA,UACN;AAAA,QACF,CAAC;AAAA,MACH;AAEA,aAAO,QAAQ,KAAK;AAAA,QAClB,QAAQ;AAAA,QACR,WAAW;AAAA,QACX,QAAQ;AAAA,QACR,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,MACpC,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAMA,SAAS,wBACP,MACA,OACA,WACA,WACA;AACA,aAAW,OAAO,MAAM;AAEtB,QAAI,IAAI,QAAQ,YAAY,MAAM,MAAM,YAAY,EAAG;AAEvD,QAAI;AACF,YAAM,UAAU,eAAe;AAAA,QAC7B,KAAK;AAAA,QACL,MAAM,IAAI;AAAA,QACV,QAAQ,IAAI;AAAA,MACd,CAAC;AAED,UAAI,QAAQ,cAAc,WAAY;AAEtC,YAAM,EAAE,IAAI,MAAM,IAAI,QAAQ;AAC9B,UACE,GAAG,YAAY,MAAM,UAAU,YAAY,KAC3C,SAAS,WACT;AACA,eAAO,EAAE,MAAM,QAAQ,KAAK,MAAM,IAAI,MAAM;AAAA,MAC9C;AAAA,IACF,QAAQ;AAEN;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@stablecoin.xyz/radius-mpp",
3
+ "version": "0.1.0",
4
+ "description": "MPP custom payment method for Radius blockchain — ERC-20 token transfers",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "src"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./client": {
17
+ "types": "./dist/client.d.ts",
18
+ "default": "./dist/client.js"
19
+ },
20
+ "./server": {
21
+ "types": "./dist/server.d.ts",
22
+ "default": "./dist/server.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsup --watch",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "test:coverage": "vitest run --coverage",
31
+ "test:watch": "vitest",
32
+ "test:unit": "vitest run test/unit",
33
+ "test:integration": "vitest run test/integration",
34
+ "prepare": "cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit"
35
+ },
36
+ "peerDependencies": {
37
+ "mppx": ">=0.4.0"
38
+ },
39
+ "dependencies": {
40
+ "viem": "^2.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@vitest/coverage-v8": "^4.1.2",
44
+ "mppx": "^0.4.11",
45
+ "tsup": "^8.0.0",
46
+ "typescript": "^5.5.0",
47
+ "vitest": "^4.1.2",
48
+ "zod": "^4.3.6"
49
+ },
50
+ "license": "MIT"
51
+ }
package/src/client.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @stablecoin.xyz/radius-mpp/client
3
+ *
4
+ * Client-side: signs and broadcasts an ERC-20 transfer on Radius,
5
+ * then returns the txHash as the Credential payload.
6
+ */
7
+
8
+ import { Credential, Method } from 'mppx'
9
+ import { Mppx } from 'mppx/client'
10
+ import {
11
+ createPublicClient,
12
+ createWalletClient,
13
+ http,
14
+ type Account,
15
+ type Chain,
16
+ type Transport,
17
+ type WalletClient,
18
+ encodeFunctionData,
19
+ } from 'viem'
20
+ import { charge, type RadiusCharge } from './index.js'
21
+ import { ERC20_ABI, resolveChain } from './constants.js'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface RadiusClientConfig {
28
+ /**
29
+ * A viem WalletClient that can sign and send transactions on Radius.
30
+ * Must have an account attached (e.g. via privateKeyToAccount or browser wallet).
31
+ */
32
+ walletClient: WalletClient<Transport, Chain, Account>
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Client method factory
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export function radius(config: RadiusClientConfig): Method.Client<RadiusCharge> {
40
+ const { walletClient } = config
41
+
42
+ return Method.toClient(charge, {
43
+ async createCredential({ challenge }) {
44
+ const { amount, chainId, token, recipient } = challenge.request
45
+
46
+ // Resolve the Radius chain definition for RPC access
47
+ const chain = resolveChain(chainId)
48
+ if (!chain) {
49
+ throw new Error(`Unsupported Radius chain ID: ${chainId}`)
50
+ }
51
+
52
+ // Build the ERC-20 transfer calldata
53
+ const data = encodeFunctionData({
54
+ abi: ERC20_ABI,
55
+ functionName: 'transfer',
56
+ args: [recipient as `0x${string}`, BigInt(amount)],
57
+ })
58
+
59
+ // Use the chain's RPC for gas estimation and receipt waiting
60
+ const publicClient = createPublicClient({
61
+ chain,
62
+ transport: http(chain.rpcUrls.default.http[0]),
63
+ })
64
+
65
+ // Estimate gas price (Radius doesn't support EIP-1559 fee estimation)
66
+ const gasPrice = await publicClient.getGasPrice()
67
+
68
+ // Send the transaction using the resolved chain's RPC
69
+ const txHash = await walletClient.sendTransaction({
70
+ to: token as `0x${string}`,
71
+ data,
72
+ chain,
73
+ gasPrice,
74
+ })
75
+
76
+ // TODO: Make confirmation count configurable (Radius has fast blocks,
77
+ // 1 confirmation should suffice but we may want to allow tuning)
78
+ await publicClient.waitForTransactionReceipt({
79
+ hash: txHash,
80
+ confirmations: 1,
81
+ })
82
+
83
+ return Credential.serialize({
84
+ challenge,
85
+ payload: { txHash },
86
+ })
87
+ },
88
+ })
89
+ }
90
+
91
+ // Re-export Mppx so consumers only need one import
92
+ export { Mppx }
@@ -0,0 +1,87 @@
1
+ import { defineChain } from 'viem'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Token addresses (SBC stablecoin on Radius)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export const SBC_TOKEN = {
8
+ mainnet: '0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb' as const,
9
+ testnet: '0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb' as const,
10
+ }
11
+
12
+ export const SBC_DECIMALS = 6
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Chain definitions (viem-compatible)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const radiusMainnet = defineChain({
19
+ id: 723487,
20
+ name: 'Radius',
21
+ nativeCurrency: { name: 'RUSD', symbol: 'RUSD', decimals: 18 },
22
+ rpcUrls: {
23
+ default: { http: ['https://rpc.radiustech.xyz'] },
24
+ },
25
+ blockExplorers: {
26
+ default: { name: 'Radius Explorer', url: 'https://network.radiustech.xyz' },
27
+ },
28
+ })
29
+
30
+ export const radiusTestnet = defineChain({
31
+ id: 72344,
32
+ name: 'Radius Testnet',
33
+ nativeCurrency: { name: 'RUSD', symbol: 'RUSD', decimals: 18 },
34
+ rpcUrls: {
35
+ default: { http: ['https://rpc.testnet.radiustech.xyz'] },
36
+ },
37
+ blockExplorers: {
38
+ default: { name: 'Radius Testnet Explorer', url: 'https://testnet.radiustech.xyz' },
39
+ },
40
+ testnet: true,
41
+ })
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Resolve chain definition by chain ID. Returns mainnet for 723 (legacy alias). */
48
+ export function resolveChain(chainId: number) {
49
+ if (chainId === radiusMainnet.id || chainId === 723) return radiusMainnet
50
+ if (chainId === radiusTestnet.id) return radiusTestnet
51
+ return null
52
+ }
53
+
54
+ /** Resolve the SBC token address for a given chain ID. */
55
+ export function resolveTokenAddress(chainId: number) {
56
+ if (chainId === radiusMainnet.id || chainId === 723) return SBC_TOKEN.mainnet
57
+ if (chainId === radiusTestnet.id) return SBC_TOKEN.testnet
58
+ return null
59
+ }
60
+
61
+ /** Build an explorer URL for a transaction hash on a given chain. */
62
+ export function explorerTxUrl(chainId: number, txHash: string): string | null {
63
+ const chain = resolveChain(chainId)
64
+ if (!chain?.blockExplorers?.default) return null
65
+ return `${chain.blockExplorers.default.url}/tx/${txHash}`
66
+ }
67
+
68
+ // Standard ERC-20 ABI subset used by client and server
69
+ export const ERC20_ABI = [
70
+ {
71
+ inputs: [
72
+ { name: 'to', type: 'address' },
73
+ { name: 'amount', type: 'uint256' },
74
+ ],
75
+ name: 'transfer',
76
+ outputs: [{ name: '', type: 'bool' }],
77
+ stateMutability: 'nonpayable',
78
+ type: 'function',
79
+ },
80
+ {
81
+ inputs: [{ name: 'account', type: 'address' }],
82
+ name: 'balanceOf',
83
+ outputs: [{ name: '', type: 'uint256' }],
84
+ stateMutability: 'view',
85
+ type: 'function',
86
+ },
87
+ ] as const
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @stablecoin.xyz/radius-mpp
3
+ *
4
+ * Shared method definition for Radius ERC-20 payments via MPP.
5
+ * Import from the root path in code that needs the schema but not
6
+ * client or server logic (e.g. shared types).
7
+ */
8
+
9
+ import { Method, z } from 'mppx'
10
+
11
+ export { radiusMainnet, radiusTestnet, SBC_TOKEN, SBC_DECIMALS, explorerTxUrl } from './constants.js'
12
+
13
+ /**
14
+ * Radius charge method — one-time ERC-20 token transfer on Radius.
15
+ *
16
+ * Request schema: the Challenge fields the server sends to the client.
17
+ * Credential payload: what the client returns after broadcasting the tx.
18
+ */
19
+ export const charge = Method.from({
20
+ intent: 'charge',
21
+ name: 'radius',
22
+ schema: {
23
+ credential: {
24
+ payload: z.object({
25
+ /** The on-chain transaction hash of the ERC-20 transfer */
26
+ txHash: z.string(),
27
+ }),
28
+ },
29
+ request: z.object({
30
+ /** Transfer amount in atomic units (e.g. "1000000" for 1 SBC with 6 decimals) */
31
+ amount: z.string(),
32
+ /** Token symbol or identifier (e.g. "SBC") */
33
+ currency: z.string(),
34
+ /** Chain ID (723487 for mainnet, 72344 for testnet) */
35
+ chainId: z.number(),
36
+ /** ERC-20 token contract address on Radius */
37
+ token: z.string(),
38
+ /** Recipient address (the seller / payee) */
39
+ recipient: z.string(),
40
+ }),
41
+ },
42
+ })
43
+
44
+ /** Re-export the inferred types for external consumers */
45
+ export type RadiusCharge = typeof charge
package/src/server.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @stablecoin.xyz/radius-mpp/server
3
+ *
4
+ * Server-side: verifies that an ERC-20 transfer actually landed on Radius
5
+ * with the correct amount, recipient, and token.
6
+ */
7
+
8
+ import { Method, Receipt } from 'mppx'
9
+ import { Mppx, Expires, Store } from 'mppx/server'
10
+ import {
11
+ createPublicClient,
12
+ http,
13
+ decodeEventLog,
14
+ type Log,
15
+ } from 'viem'
16
+ import { charge, type RadiusCharge } from './index.js'
17
+ import { resolveChain, SBC_DECIMALS } from './constants.js'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // ERC-20 Transfer event ABI (for log decoding)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const TRANSFER_EVENT_ABI = [
24
+ {
25
+ type: 'event',
26
+ name: 'Transfer',
27
+ inputs: [
28
+ { indexed: true, name: 'from', type: 'address' },
29
+ { indexed: true, name: 'to', type: 'address' },
30
+ { indexed: false, name: 'value', type: 'uint256' },
31
+ ],
32
+ },
33
+ ] as const
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export interface RadiusServerConfig {
40
+ /**
41
+ * Optional custom RPC URL. If not provided, uses the default from chain config.
42
+ */
43
+ rpcUrl?: string
44
+
45
+ /**
46
+ * Number of confirmations to require before accepting the tx.
47
+ * Defaults to 1 (Radius has fast finality).
48
+ */
49
+ confirmations?: number
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Server method factory
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export function radius(config: RadiusServerConfig = {}): Method.Server<RadiusCharge> {
57
+ const { confirmations = 1 } = config
58
+
59
+ return Method.toServer(charge, {
60
+ async verify({ credential }) {
61
+ const { txHash } = credential.payload
62
+ const { amount, chainId, token, recipient } = credential.challenge.request
63
+
64
+ // Resolve chain
65
+ const chain = resolveChain(chainId)
66
+ if (!chain) {
67
+ throw new Error(`Unsupported Radius chain ID: ${chainId}`)
68
+ }
69
+
70
+ const rpcUrl = config.rpcUrl ?? chain.rpcUrls.default.http[0]
71
+ const publicClient = createPublicClient({
72
+ chain,
73
+ transport: http(rpcUrl),
74
+ })
75
+
76
+ // Fetch the transaction receipt
77
+ const receipt = await publicClient.getTransactionReceipt({
78
+ hash: txHash as `0x${string}`,
79
+ })
80
+
81
+ // Check tx succeeded
82
+ if (receipt.status !== 'success') {
83
+ throw new Error(`Transaction ${txHash} failed on-chain (status: ${receipt.status})`)
84
+ }
85
+
86
+ // Check the tx was sent to the correct token contract
87
+ // TODO: Also verify receipt.from if we want to enforce payer identity
88
+ const txToAddress = receipt.to?.toLowerCase()
89
+ if (txToAddress !== token.toLowerCase()) {
90
+ throw new Error(
91
+ `Transaction target ${txToAddress} does not match expected token ${token}`
92
+ )
93
+ }
94
+
95
+ // Find the Transfer event log that matches recipient and amount
96
+ const transferLog = findMatchingTransferLog(
97
+ receipt.logs,
98
+ token,
99
+ recipient,
100
+ BigInt(amount),
101
+ )
102
+
103
+ if (!transferLog) {
104
+ throw new Error(
105
+ `No matching ERC-20 Transfer event found for recipient ${recipient} with amount >= ${amount}`
106
+ )
107
+ }
108
+
109
+ // Optionally wait for additional confirmations
110
+ if (confirmations > 1) {
111
+ await publicClient.waitForTransactionReceipt({
112
+ hash: txHash as `0x${string}`,
113
+ confirmations,
114
+ })
115
+ }
116
+
117
+ return Receipt.from({
118
+ method: 'radius',
119
+ reference: txHash,
120
+ status: 'success',
121
+ timestamp: new Date().toISOString(),
122
+ })
123
+ },
124
+ })
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Helpers
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function findMatchingTransferLog(
132
+ logs: Log[],
133
+ token: string,
134
+ recipient: string,
135
+ minAmount: bigint,
136
+ ) {
137
+ for (const log of logs) {
138
+ // Only look at logs from the correct token contract
139
+ if (log.address.toLowerCase() !== token.toLowerCase()) continue
140
+
141
+ try {
142
+ const decoded = decodeEventLog({
143
+ abi: TRANSFER_EVENT_ABI,
144
+ data: log.data,
145
+ topics: log.topics,
146
+ })
147
+
148
+ if (decoded.eventName !== 'Transfer') continue
149
+
150
+ const { to, value } = decoded.args
151
+ if (
152
+ to.toLowerCase() === recipient.toLowerCase() &&
153
+ value >= minAmount
154
+ ) {
155
+ return { from: decoded.args.from, to, value }
156
+ }
157
+ } catch {
158
+ // Not a Transfer event from this ABI, skip
159
+ continue
160
+ }
161
+ }
162
+
163
+ return null
164
+ }
165
+
166
+ // Re-export Mppx, Expires, Store so consumers only need one import
167
+ export { Mppx, Expires, Store }