@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 +172 -0
- package/dist/chunk-TM4JSIP6.js +100 -0
- package/dist/chunk-TM4JSIP6.js.map +1 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +55 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +104 -0
- package/dist/server.js.map +1 -0
- package/package.json +51 -0
- package/src/client.ts +92 -0
- package/src/constants.ts +87 -0
- package/src/index.ts +45 -0
- package/src/server.ts +167 -0
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":[]}
|
package/dist/client.d.ts
ADDED
|
@@ -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":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -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":[]}
|
package/dist/server.d.ts
ADDED
|
@@ -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 }
|
package/src/constants.ts
ADDED
|
@@ -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 }
|