@heraldprotocol/mpp 0.0.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.
@@ -0,0 +1,100 @@
1
+ export const chainId = {
2
+ mainnet: 16661,
3
+ testnet: 16602,
4
+ } as const;
5
+
6
+ export type ChainId = (typeof chainId)[keyof typeof chainId];
7
+
8
+ const USDC_E = "0x1f3aa82227281ca364bfb3d253b0f1af1da6473e";
9
+
10
+ /** Chain ID → default currency. */
11
+ export const currency: Partial<Record<ChainId, string>> = {
12
+ [chainId.mainnet]: USDC_E,
13
+ };
14
+
15
+ /** Default token decimals for USDC.e. */
16
+ export const decimals = 6;
17
+
18
+ /** Default RPC URLs per chain. */
19
+ export const rpcUrl: Record<number, string> = {
20
+ [chainId.mainnet]: "https://evmrpc.0g.ai",
21
+ [chainId.testnet]: "https://evmrpc-testnet.0g.ai",
22
+ };
23
+
24
+ /** ERC-3009 ABI (`transferWithAuthorization`, `receiveWithAuthorization`). */
25
+ export const erc3009Abi = [
26
+ {
27
+ type: "function",
28
+ name: "transferWithAuthorization",
29
+ inputs: [
30
+ { name: "from", type: "address" },
31
+ { name: "to", type: "address" },
32
+ { name: "value", type: "uint256" },
33
+ { name: "validAfter", type: "uint256" },
34
+ { name: "validBefore", type: "uint256" },
35
+ { name: "nonce", type: "bytes32" },
36
+ { name: "v", type: "uint8" },
37
+ { name: "r", type: "bytes32" },
38
+ { name: "s", type: "bytes32" },
39
+ ],
40
+ outputs: [],
41
+ stateMutability: "nonpayable",
42
+ },
43
+ {
44
+ type: "function",
45
+ name: "receiveWithAuthorization",
46
+ inputs: [
47
+ { name: "from", type: "address" },
48
+ { name: "to", type: "address" },
49
+ { name: "value", type: "uint256" },
50
+ { name: "validAfter", type: "uint256" },
51
+ { name: "validBefore", type: "uint256" },
52
+ { name: "nonce", type: "bytes32" },
53
+ { name: "v", type: "uint8" },
54
+ { name: "r", type: "bytes32" },
55
+ { name: "s", type: "bytes32" },
56
+ ],
57
+ outputs: [],
58
+ stateMutability: "nonpayable",
59
+ },
60
+ {
61
+ type: "function",
62
+ name: "name",
63
+ inputs: [],
64
+ outputs: [{ name: "", type: "string" }],
65
+ stateMutability: "view",
66
+ },
67
+ {
68
+ type: "function",
69
+ name: "version",
70
+ inputs: [],
71
+ outputs: [{ name: "", type: "string" }],
72
+ stateMutability: "view",
73
+ },
74
+ ] as const;
75
+
76
+ /**
77
+ * Known tokens that support ERC-3009 (TransferWithAuthorization).
78
+ * Keyed by lowercase address for case-insensitive lookup.
79
+ */
80
+ export const erc3009Tokens: Record<string, { name: string; version: string }> =
81
+ {
82
+ [USDC_E.toLowerCase()]: {
83
+ name: "Bridged USDC",
84
+ version: "2",
85
+ },
86
+ };
87
+
88
+ /** Resolves the default currency for a given chain. */
89
+ export function resolveCurrency(parameters: {
90
+ chainId?: number | undefined;
91
+ testnet?: boolean | undefined;
92
+ }): string {
93
+ const id =
94
+ parameters.chainId ??
95
+ (parameters.testnet ? chainId.testnet : chainId.mainnet);
96
+ const resolved = currency[id as ChainId];
97
+ if (!resolved)
98
+ throw new Error(`No default currency configured for chainId ${id}.`);
99
+ return resolved;
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { charge } from "./Methods.js";
@@ -0,0 +1,382 @@
1
+ import type { Account, Address, Chain, Client, TransactionReceipt } from "viem";
2
+ import { Method, Store } from "mppx";
3
+ import {
4
+ encodeFunctionData,
5
+ erc20Abi,
6
+ isAddressEqual,
7
+ keccak256,
8
+ parseEventLogs,
9
+ } from "viem";
10
+ import { parseAccount } from "viem/accounts";
11
+ import {
12
+ getTransactionReceipt,
13
+ sendTransaction,
14
+ sendTransactionSync,
15
+ } from "viem/actions";
16
+
17
+ import type { MaybePromise } from "../types.js";
18
+ import * as defaults from "../defaults.js";
19
+ import * as Methods from "../Methods.js";
20
+
21
+ /**
22
+ * Creates a 0G charge method intent for usage on the server.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { zerog } from "@heraldprotocol/mpp/server";
27
+ *
28
+ * const charge = zerog.charge({
29
+ * recipient: "0x...",
30
+ * currency: "0x...",
31
+ * account: privateKeyToAccount("0x..."),
32
+ * });
33
+ * ```
34
+ */
35
+ export function charge(parameters: charge.Parameters = {}): Method.AnyServer {
36
+ const {
37
+ amount,
38
+ currency = defaults.resolveCurrency(parameters),
39
+ decimals = defaults.decimals,
40
+ description,
41
+ externalId,
42
+ recipient,
43
+ waitForConfirmation = true,
44
+ } = parameters;
45
+ const store = (parameters.store ??
46
+ Store.memory()) as Store.Store<charge.StoreItemMap>;
47
+
48
+ if (currency.toLowerCase() in defaults.erc3009Tokens && !parameters.account) {
49
+ throw new Error(
50
+ "ERC-3009 requires an `account` parameter so the server can sign and broadcast " +
51
+ "the transferWithAuthorization transaction."
52
+ );
53
+ }
54
+
55
+ const serverAccount = parameters.account
56
+ ? typeof parameters.account === "string"
57
+ ? parseAccount(parameters.account)
58
+ : parameters.account
59
+ : undefined;
60
+
61
+ const resolveClient = async (
62
+ chainId?: number | undefined
63
+ ): Promise<Client> => {
64
+ if (parameters.getClient) return parameters.getClient({ chainId });
65
+ const id = chainId ?? defaults.chainId.mainnet;
66
+ const url = defaults.rpcUrl[id];
67
+ if (!url) throw new Error(`No RPC URL configured for chainId ${id}.`);
68
+ const { createClient, http } = await import("viem");
69
+ return createClient({ chain: { id } as Chain, transport: http(url) });
70
+ };
71
+
72
+ return Method.toServer(Methods.charge, {
73
+ defaults: {
74
+ amount,
75
+ currency,
76
+ decimals,
77
+ description,
78
+ externalId,
79
+ recipient,
80
+ } as never,
81
+
82
+ async request({ request }) {
83
+ const chainId = await (async () => {
84
+ if (request.chainId) return request.chainId;
85
+ if (parameters.testnet) return defaults.chainId.testnet;
86
+ return (await resolveClient(undefined)).chain?.id;
87
+ })();
88
+
89
+ const client = await (async () => {
90
+ try {
91
+ return await resolveClient(chainId);
92
+ } catch {
93
+ throw new Error(`No client configured with chainId ${chainId}.`);
94
+ }
95
+ })();
96
+ if (client.chain?.id !== chainId)
97
+ throw new Error(`Client not configured with chainId ${chainId}.`);
98
+
99
+ return { ...request, chainId };
100
+ },
101
+
102
+ async verify({ credential, request }) {
103
+ const { challenge } = credential;
104
+ const { chainId } = request;
105
+
106
+ const client = await resolveClient(chainId);
107
+
108
+ const { request: challengeRequest } = challenge;
109
+ const challengeAmount = challengeRequest.amount as string;
110
+ const challengeCurrency = challengeRequest.currency as Address;
111
+ const challengeRecipient = challengeRequest.recipient as Address;
112
+ const expires = challenge.expires;
113
+
114
+ if (expires && new Date(expires) < new Date()) {
115
+ throw new Error(`Payment expired at ${expires}.`);
116
+ }
117
+
118
+ const payload = credential.payload;
119
+
120
+ switch (payload.type) {
121
+ case "hash": {
122
+ const hash = payload.hash as `0x${string}`;
123
+ await assertHashUnused(store, hash);
124
+
125
+ const sender = extractDidAddress(credential.source);
126
+ if (!sender)
127
+ throw new Error(
128
+ "Hash credential is missing a valid `source` DID — cannot verify sender."
129
+ );
130
+
131
+ const receipt = await getTransactionReceipt(client, { hash });
132
+
133
+ const transferLogs = parseEventLogs({
134
+ abi: erc20Abi,
135
+ eventName: "Transfer",
136
+ logs: receipt.logs,
137
+ });
138
+
139
+ const match = transferLogs.find(
140
+ (log) =>
141
+ isAddressEqual(log.address, challengeCurrency) &&
142
+ isAddressEqual(log.args.from, sender) &&
143
+ isAddressEqual(log.args.to, challengeRecipient) &&
144
+ log.args.value.toString() === challengeAmount
145
+ );
146
+
147
+ if (!match)
148
+ throw new MismatchError(
149
+ "Payment verification failed: no matching ERC-20 transfer found.",
150
+ {
151
+ sender,
152
+ amount: challengeAmount,
153
+ currency: challengeCurrency,
154
+ recipient: challengeRecipient,
155
+ }
156
+ );
157
+
158
+ await markHashUsed(store, hash);
159
+
160
+ return toReceipt(receipt);
161
+ }
162
+
163
+ case "authorization": {
164
+ if (!serverAccount) {
165
+ throw new Error(
166
+ "Received ERC-3009 authorization credential but no server `account` is configured. " +
167
+ "Set `account` in charge parameters to broadcast transferWithAuthorization."
168
+ );
169
+ }
170
+
171
+ const { from, to, value, validAfter, validBefore, nonce, signature } =
172
+ payload as {
173
+ from: string;
174
+ to: string;
175
+ value: string;
176
+ validAfter: string;
177
+ validBefore: string;
178
+ nonce: string;
179
+ signature: string;
180
+ };
181
+
182
+ // Split signature into v, r, s for the contract call
183
+ const r = `0x${signature.slice(2, 66)}` as `0x${string}`;
184
+ const s = `0x${signature.slice(66, 130)}` as `0x${string}`;
185
+ const v = parseInt(signature.slice(130, 132), 16);
186
+
187
+ // Validate authorization parameters match the challenge
188
+ if (!isAddressEqual(to as Address, challengeRecipient))
189
+ throw new MismatchError(
190
+ "Authorization recipient does not match challenge.",
191
+ { expected: challengeRecipient, actual: to }
192
+ );
193
+
194
+ if (value !== challengeAmount)
195
+ throw new MismatchError(
196
+ "Authorization amount does not match challenge.",
197
+ { expected: challengeAmount, actual: value }
198
+ );
199
+
200
+ // Check expiry from the authorization itself
201
+ const validBeforeTs = Number(validBefore);
202
+ if (
203
+ validBeforeTs > 0 &&
204
+ validBeforeTs < Math.floor(Date.now() / 1000)
205
+ ) {
206
+ throw new Error(
207
+ `ERC-3009 authorization expired (validBefore: ${validBefore}).`
208
+ );
209
+ }
210
+
211
+ const hash = keccak256(signature as `0x${string}`);
212
+ await assertHashUnused(store, hash);
213
+ await markHashUsed(store, hash);
214
+
215
+ if (waitForConfirmation) {
216
+ const receipt = await sendTransactionSync(client, {
217
+ account: serverAccount,
218
+ chain: client.chain,
219
+ to: challengeCurrency,
220
+ data: encodeFunctionData({
221
+ abi: defaults.erc3009Abi,
222
+ functionName: "transferWithAuthorization",
223
+ args: [
224
+ from as Address,
225
+ to as Address,
226
+ BigInt(value),
227
+ BigInt(validAfter),
228
+ BigInt(validBefore),
229
+ nonce as `0x${string}`,
230
+ v,
231
+ r as `0x${string}`,
232
+ s as `0x${string}`,
233
+ ],
234
+ }),
235
+ } as never);
236
+
237
+ return toReceipt(receipt);
238
+ }
239
+
240
+ const txHash = await sendTransaction(client, {
241
+ account: serverAccount,
242
+ chain: client.chain,
243
+ to: challengeCurrency,
244
+ data: encodeFunctionData({
245
+ abi: defaults.erc3009Abi,
246
+ functionName: "transferWithAuthorization",
247
+ args: [
248
+ from as Address,
249
+ to as Address,
250
+ BigInt(value),
251
+ BigInt(validAfter),
252
+ BigInt(validBefore),
253
+ nonce as `0x${string}`,
254
+ v,
255
+ r as `0x${string}`,
256
+ s as `0x${string}`,
257
+ ],
258
+ }),
259
+ } as never);
260
+
261
+ return {
262
+ method: "zerog" as const,
263
+ status: "success" as const,
264
+ timestamp: new Date().toISOString(),
265
+ reference: txHash,
266
+ };
267
+ }
268
+
269
+ default:
270
+ throw new Error(
271
+ `Unsupported credential type "${(payload as { type: string }).type}".`
272
+ );
273
+ }
274
+ },
275
+ });
276
+ }
277
+
278
+ export declare namespace charge {
279
+ type StoreItemMap = {
280
+ [key: `mppx:charge:${string}`]: number;
281
+ };
282
+
283
+ type Parameters = {
284
+ /** Default payment amount (human-readable, e.g. "1.50"). */
285
+ amount?: string | undefined;
286
+ /** ERC-20 token contract address. */
287
+ currency?: string | undefined;
288
+ /** Token decimals. @default 6 */
289
+ decimals?: number | undefined;
290
+ /** Human-readable description. */
291
+ description?: string | undefined;
292
+ /** External identifier to echo back in receipt. */
293
+ externalId?: string | undefined;
294
+ /** Recipient address for payments. */
295
+ recipient?: string | undefined;
296
+ /** Testnet mode. */
297
+ testnet?: boolean | undefined;
298
+ /**
299
+ * Whether to wait for the charge transaction to confirm on-chain.
300
+ * @default true
301
+ */
302
+ waitForConfirmation?: boolean | undefined;
303
+ /** Function that returns a viem Client for the given chain ID. */
304
+ getClient?:
305
+ | ((parameters: { chainId?: number | undefined }) => MaybePromise<Client>)
306
+ | undefined;
307
+ /**
308
+ * Server account used to broadcast `transferWithAuthorization` transactions.
309
+ * Required when accepting `authorization` payloads. The server pays gas
310
+ * from this account.
311
+ */
312
+ account?: Account | Address | undefined;
313
+ /**
314
+ * Store for transaction hash replay protection.
315
+ *
316
+ * Use a shared store in multi-instance deployments so consumed hashes are
317
+ * visible across all server instances.
318
+ */
319
+ store?: Store.Store | undefined;
320
+ };
321
+ }
322
+
323
+ /** @internal */
324
+ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` {
325
+ return `mppx:charge:${hash.toLowerCase()}`;
326
+ }
327
+
328
+ /** @internal */
329
+ async function assertHashUnused(
330
+ store: Store.Store<charge.StoreItemMap>,
331
+ hash: `0x${string}`
332
+ ): Promise<void> {
333
+ const seen = await store.get(getHashStoreKey(hash));
334
+ if (seen !== null) throw new Error("Transaction hash has already been used.");
335
+ }
336
+
337
+ /** @internal */
338
+ async function markHashUsed(
339
+ store: Store.Store<charge.StoreItemMap>,
340
+ hash: `0x${string}`
341
+ ): Promise<void> {
342
+ await store.put(getHashStoreKey(hash), Date.now());
343
+ }
344
+
345
+ /** @internal */
346
+ function toReceipt(receipt: TransactionReceipt) {
347
+ const { status, transactionHash } = receipt;
348
+ if (status !== "success") {
349
+ throw new Error(`Transaction reverted: ${transactionHash}`);
350
+ }
351
+ return {
352
+ method: "zerog" as const,
353
+ status: "success" as const,
354
+ timestamp: new Date().toISOString(),
355
+ reference: transactionHash,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Extracts an Ethereum address from a `did:pkh:eip155:<chainId>:<address>` DID.
361
+ * Returns `undefined` if the source is missing or malformed.
362
+ * @internal
363
+ */
364
+ function extractDidAddress(source: string | undefined): Address | undefined {
365
+ if (!source) return undefined;
366
+ const match = /^did:pkh:eip155:\d+:(0x[0-9a-fA-F]{40})$/.exec(source);
367
+ return match ? (match[1] as Address) : undefined;
368
+ }
369
+
370
+ /** @internal */
371
+ class MismatchError extends Error {
372
+ override readonly name = "MismatchError";
373
+
374
+ constructor(reason: string, details: Record<string, string>) {
375
+ super(
376
+ [
377
+ reason,
378
+ ...Object.entries(details).map(([k, v]) => ` - ${k}: ${v}`),
379
+ ].join("\n")
380
+ );
381
+ }
382
+ }
@@ -0,0 +1,29 @@
1
+ import type { Method } from "mppx";
2
+
3
+ import { charge as charge_ } from "./Charge.js";
4
+
5
+ /**
6
+ * Creates a 0G `charge` server method.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { Mppx } from "mppx/server";
11
+ * import { zerog } from "@heraldprotocol/mpp/server";
12
+ *
13
+ * const mppx = Mppx.create({
14
+ * methods: [zerog({ recipient: "0x...", currency: "0x..." })],
15
+ * });
16
+ * ```
17
+ */
18
+ export function zerog(
19
+ parameters?: zerog.Parameters
20
+ ): readonly [Method.AnyServer] {
21
+ return [zerog.charge(parameters)] as const;
22
+ }
23
+
24
+ export namespace zerog {
25
+ export type Parameters = charge_.Parameters;
26
+
27
+ /** Creates a 0G `charge` method for one-time ERC-20 token transfers. */
28
+ export const charge = charge_;
29
+ }
@@ -0,0 +1,2 @@
1
+ export { charge } from "./Charge.js";
2
+ export { zerog } from "./Methods.js";
package/src/types.ts ADDED
@@ -0,0 +1 @@
1
+ export type MaybePromise<T> = T | Promise<T>;