@stablecoin.xyz/radius-mpp 0.1.0 → 0.1.1

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 CHANGED
@@ -84,7 +84,29 @@ const mppx = Mppx.create({
84
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
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
86
 
87
- Both are optional — `radius()` with no arguments works out of the box using chain defaults.
87
+ All options are optional — `radius()` with no arguments works out of the box using chain defaults.
88
+
89
+ ### Payer enforcement
90
+
91
+ By default, any wallet can pay for a gated endpoint. If you need to restrict payments to a specific wallet (e.g. the authenticated user's wallet), pass `payer` in the charge options:
92
+
93
+ ```ts
94
+ app.get(
95
+ '/premium',
96
+ mppx.charge({
97
+ amount: '1000000',
98
+ currency: 'SBC',
99
+ chainId: radiusMainnet.id,
100
+ token: SBC_TOKEN.mainnet,
101
+ recipient: MERCHANT_WALLET,
102
+ payer: userSession.walletAddress, // only accept payment from this wallet
103
+ expires: Expires.minutes(5),
104
+ }),
105
+ handler,
106
+ )
107
+ ```
108
+
109
+ When `payer` is set, the server verifies that `receipt.from` matches the specified address. If someone else's wallet pays, the server rejects the payment. When `payer` is omitted, any wallet can pay.
88
110
 
89
111
  See [`examples/`](./examples) for complete working examples with Hono, Express, Next.js, and Elysia.
90
112
 
@@ -82,7 +82,13 @@ var charge = Method.from({
82
82
  /** ERC-20 token contract address on Radius */
83
83
  token: z.string(),
84
84
  /** Recipient address (the seller / payee) */
85
- recipient: z.string()
85
+ recipient: z.string(),
86
+ /**
87
+ * Optional payer address to enforce sender identity.
88
+ * When set, the server rejects payments from any other wallet.
89
+ * Useful when the paying wallet must match an authenticated session.
90
+ */
91
+ payer: z.optional(z.string())
86
92
  })
87
93
  }
88
94
  });
@@ -97,4 +103,4 @@ export {
97
103
  ERC20_ABI,
98
104
  charge
99
105
  };
100
- //# sourceMappingURL=chunk-TM4JSIP6.js.map
106
+ //# sourceMappingURL=chunk-HPDJWSJ3.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 * Optional payer address to enforce sender identity.\n * When set, the server rejects payments from any other wallet.\n * Useful when the paying wallet must match an authenticated session.\n */\n payer: z.optional(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;AAAA;AAAA;AAAA;AAAA;AAAA,MAMpB,OAAO,EAAE,SAAS,EAAE,OAAO,CAAC;AAAA,IAC9B,CAAC;AAAA,EACH;AACF,CAAC;","names":[]}
package/dist/client.d.ts CHANGED
@@ -16,6 +16,11 @@ interface RadiusClientConfig {
16
16
  * Must have an account attached (e.g. via privateKeyToAccount or browser wallet).
17
17
  */
18
18
  walletClient: WalletClient<Transport, Chain, Account>;
19
+ /**
20
+ * Number of block confirmations to wait before returning the credential.
21
+ * Defaults to 1 (Radius has fast finality).
22
+ */
23
+ confirmations?: number;
19
24
  }
20
25
  declare function radius(config: RadiusClientConfig): Method.Client<RadiusCharge>;
21
26
 
package/dist/client.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  ERC20_ABI,
3
3
  charge,
4
4
  resolveChain
5
- } from "./chunk-TM4JSIP6.js";
5
+ } from "./chunk-HPDJWSJ3.js";
6
6
 
7
7
  // src/client.ts
8
8
  import { Credential, Method } from "mppx";
@@ -13,7 +13,7 @@ import {
13
13
  encodeFunctionData
14
14
  } from "viem";
15
15
  function radius(config) {
16
- const { walletClient } = config;
16
+ const { walletClient, confirmations = 1 } = config;
17
17
  return Method.toClient(charge, {
18
18
  async createCredential({ challenge }) {
19
19
  const { amount, chainId, token, recipient } = challenge.request;
@@ -39,7 +39,7 @@ function radius(config) {
39
39
  });
40
40
  await publicClient.waitForTransactionReceipt({
41
41
  hash: txHash,
42
- confirmations: 1
42
+ confirmations
43
43
  });
44
44
  return Credential.serialize({
45
45
  challenge,
@@ -1 +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":[]}
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 * Number of block confirmations to wait before returning the credential.\n * Defaults to 1 (Radius has fast finality).\n */\n confirmations?: number\n}\n\n// ---------------------------------------------------------------------------\n// Client method factory\n// ---------------------------------------------------------------------------\n\nexport function radius(config: RadiusClientConfig): Method.Client<RadiusCharge> {\n const { walletClient, confirmations = 1 } = 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 await publicClient.waitForTransactionReceipt({\n hash: txHash,\n confirmations,\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;AA0BA,SAAS,OAAO,QAAyD;AAC9E,QAAM,EAAE,cAAc,gBAAgB,EAAE,IAAI;AAE5C,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;AAED,YAAM,aAAa,0BAA0B;AAAA,QAC3C,MAAM;AAAA,QACN;AAAA,MACF,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 CHANGED
@@ -138,6 +138,12 @@ declare const charge: {
138
138
  token: z.ZodMiniString<string>;
139
139
  /** Recipient address (the seller / payee) */
140
140
  recipient: z.ZodMiniString<string>;
141
+ /**
142
+ * Optional payer address to enforce sender identity.
143
+ * When set, the server rejects payments from any other wallet.
144
+ * Useful when the paying wallet must match an authenticated session.
145
+ */
146
+ payer: z.ZodMiniOptional<z.ZodMiniString<string>>;
141
147
  }, z.core.$strip>;
142
148
  };
143
149
  };
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  explorerTxUrl,
6
6
  radiusMainnet,
7
7
  radiusTestnet
8
- } from "./chunk-TM4JSIP6.js";
8
+ } from "./chunk-HPDJWSJ3.js";
9
9
  export {
10
10
  SBC_DECIMALS,
11
11
  SBC_TOKEN,
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  charge,
3
3
  resolveChain
4
- } from "./chunk-TM4JSIP6.js";
4
+ } from "./chunk-HPDJWSJ3.js";
5
5
 
6
6
  // src/server.ts
7
7
  import { Method, Receipt } from "mppx";
@@ -27,7 +27,7 @@ function radius(config = {}) {
27
27
  return Method.toServer(charge, {
28
28
  async verify({ credential }) {
29
29
  const { txHash } = credential.payload;
30
- const { amount, chainId, token, recipient } = credential.challenge.request;
30
+ const { amount, chainId, token, recipient, payer } = credential.challenge.request;
31
31
  const chain = resolveChain(chainId);
32
32
  if (!chain) {
33
33
  throw new Error(`Unsupported Radius chain ID: ${chainId}`);
@@ -43,6 +43,11 @@ function radius(config = {}) {
43
43
  if (receipt.status !== "success") {
44
44
  throw new Error(`Transaction ${txHash} failed on-chain (status: ${receipt.status})`);
45
45
  }
46
+ if (payer && receipt.from.toLowerCase() !== payer.toLowerCase()) {
47
+ throw new Error(
48
+ `Transaction sender ${receipt.from} does not match expected payer ${payer}`
49
+ );
50
+ }
46
51
  const txToAddress = receipt.to?.toLowerCase();
47
52
  if (txToAddress !== token.toLowerCase()) {
48
53
  throw new Error(
@@ -1 +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":[]}
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, payer } = 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 // If payer is specified, verify the tx was sent by the expected wallet\n if (payer && receipt.from.toLowerCase() !== payer.toLowerCase()) {\n throw new Error(\n `Transaction sender ${receipt.from} does not match expected payer ${payer}`\n )\n }\n\n // Check the tx was sent to the correct token contract\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":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stablecoin.xyz/radius-mpp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MPP custom payment method for Radius blockchain — ERC-20 token transfers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/client.ts CHANGED
@@ -30,6 +30,12 @@ export interface RadiusClientConfig {
30
30
  * Must have an account attached (e.g. via privateKeyToAccount or browser wallet).
31
31
  */
32
32
  walletClient: WalletClient<Transport, Chain, Account>
33
+
34
+ /**
35
+ * Number of block confirmations to wait before returning the credential.
36
+ * Defaults to 1 (Radius has fast finality).
37
+ */
38
+ confirmations?: number
33
39
  }
34
40
 
35
41
  // ---------------------------------------------------------------------------
@@ -37,7 +43,7 @@ export interface RadiusClientConfig {
37
43
  // ---------------------------------------------------------------------------
38
44
 
39
45
  export function radius(config: RadiusClientConfig): Method.Client<RadiusCharge> {
40
- const { walletClient } = config
46
+ const { walletClient, confirmations = 1 } = config
41
47
 
42
48
  return Method.toClient(charge, {
43
49
  async createCredential({ challenge }) {
@@ -73,11 +79,9 @@ export function radius(config: RadiusClientConfig): Method.Client<RadiusCharge>
73
79
  gasPrice,
74
80
  })
75
81
 
76
- // TODO: Make confirmation count configurable (Radius has fast blocks,
77
- // 1 confirmation should suffice but we may want to allow tuning)
78
82
  await publicClient.waitForTransactionReceipt({
79
83
  hash: txHash,
80
- confirmations: 1,
84
+ confirmations,
81
85
  })
82
86
 
83
87
  return Credential.serialize({
package/src/index.ts CHANGED
@@ -37,6 +37,12 @@ export const charge = Method.from({
37
37
  token: z.string(),
38
38
  /** Recipient address (the seller / payee) */
39
39
  recipient: z.string(),
40
+ /**
41
+ * Optional payer address to enforce sender identity.
42
+ * When set, the server rejects payments from any other wallet.
43
+ * Useful when the paying wallet must match an authenticated session.
44
+ */
45
+ payer: z.optional(z.string()),
40
46
  }),
41
47
  },
42
48
  })
package/src/server.ts CHANGED
@@ -59,7 +59,7 @@ export function radius(config: RadiusServerConfig = {}): Method.Server<RadiusCha
59
59
  return Method.toServer(charge, {
60
60
  async verify({ credential }) {
61
61
  const { txHash } = credential.payload
62
- const { amount, chainId, token, recipient } = credential.challenge.request
62
+ const { amount, chainId, token, recipient, payer } = credential.challenge.request
63
63
 
64
64
  // Resolve chain
65
65
  const chain = resolveChain(chainId)
@@ -83,8 +83,14 @@ export function radius(config: RadiusServerConfig = {}): Method.Server<RadiusCha
83
83
  throw new Error(`Transaction ${txHash} failed on-chain (status: ${receipt.status})`)
84
84
  }
85
85
 
86
+ // If payer is specified, verify the tx was sent by the expected wallet
87
+ if (payer && receipt.from.toLowerCase() !== payer.toLowerCase()) {
88
+ throw new Error(
89
+ `Transaction sender ${receipt.from} does not match expected payer ${payer}`
90
+ )
91
+ }
92
+
86
93
  // Check the tx was sent to the correct token contract
87
- // TODO: Also verify receipt.from if we want to enforce payer identity
88
94
  const txToAddress = receipt.to?.toLowerCase()
89
95
  if (txToAddress !== token.toLowerCase()) {
90
96
  throw new Error(
@@ -1 +0,0 @@
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":[]}