mppx 0.4.11 → 0.4.12

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/internal/env.d.ts +1 -1
  3. package/dist/internal/env.d.ts.map +1 -1
  4. package/dist/internal/env.js +2 -6
  5. package/dist/internal/env.js.map +1 -1
  6. package/dist/internal/types.d.ts +23 -0
  7. package/dist/internal/types.d.ts.map +1 -1
  8. package/dist/server/Mppx.d.ts +1 -1
  9. package/dist/server/Mppx.d.ts.map +1 -1
  10. package/dist/server/Mppx.js +49 -2
  11. package/dist/server/Mppx.js.map +1 -1
  12. package/dist/tempo/Methods.d.ts +15 -0
  13. package/dist/tempo/Methods.d.ts.map +1 -1
  14. package/dist/tempo/Methods.js +27 -3
  15. package/dist/tempo/Methods.js.map +1 -1
  16. package/dist/tempo/client/Charge.d.ts +21 -0
  17. package/dist/tempo/client/Charge.d.ts.map +1 -1
  18. package/dist/tempo/client/Charge.js +33 -7
  19. package/dist/tempo/client/Charge.js.map +1 -1
  20. package/dist/tempo/client/Methods.d.ts +15 -0
  21. package/dist/tempo/client/Methods.d.ts.map +1 -1
  22. package/dist/tempo/internal/account.d.ts +5 -11
  23. package/dist/tempo/internal/account.d.ts.map +1 -1
  24. package/dist/tempo/internal/charge.d.ts +20 -0
  25. package/dist/tempo/internal/charge.d.ts.map +1 -0
  26. package/dist/tempo/internal/charge.js +23 -0
  27. package/dist/tempo/internal/charge.js.map +1 -0
  28. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  29. package/dist/tempo/internal/fee-payer.js +15 -3
  30. package/dist/tempo/internal/fee-payer.js.map +1 -1
  31. package/dist/tempo/server/Charge.d.ts +17 -2
  32. package/dist/tempo/server/Charge.d.ts.map +1 -1
  33. package/dist/tempo/server/Charge.js +148 -99
  34. package/dist/tempo/server/Charge.js.map +1 -1
  35. package/dist/tempo/server/Methods.d.ts +17 -2
  36. package/dist/tempo/server/Methods.d.ts.map +1 -1
  37. package/dist/tempo/server/Methods.js +4 -1
  38. package/dist/tempo/server/Methods.js.map +1 -1
  39. package/dist/tempo/server/Session.d.ts +9 -4
  40. package/dist/tempo/server/Session.d.ts.map +1 -1
  41. package/dist/tempo/server/Session.js +18 -3
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/session/Chain.d.ts +18 -2
  44. package/dist/tempo/session/Chain.d.ts.map +1 -1
  45. package/dist/tempo/session/Chain.js +18 -14
  46. package/dist/tempo/session/Chain.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/internal/env.test.ts +12 -12
  49. package/src/internal/env.ts +2 -6
  50. package/src/internal/types.ts +25 -0
  51. package/src/server/Mppx.test.ts +287 -0
  52. package/src/server/Mppx.ts +59 -5
  53. package/src/tempo/Methods.test.ts +79 -0
  54. package/src/tempo/Methods.ts +53 -17
  55. package/src/tempo/client/Charge.ts +41 -8
  56. package/src/tempo/internal/account.ts +7 -14
  57. package/src/tempo/internal/charge.ts +43 -0
  58. package/src/tempo/internal/fee-payer.test.ts +33 -14
  59. package/src/tempo/internal/fee-payer.ts +21 -6
  60. package/src/tempo/server/Charge.test.ts +231 -0
  61. package/src/tempo/server/Charge.ts +193 -124
  62. package/src/tempo/server/Methods.ts +4 -1
  63. package/src/tempo/server/Session.test.ts +28 -0
  64. package/src/tempo/server/Session.ts +26 -17
  65. package/src/tempo/session/Chain.test.ts +25 -5
  66. package/src/tempo/session/Chain.ts +30 -14
@@ -1567,6 +1567,77 @@ describe('cross-route credential replay via scope binding flaw', () => {
1567
1567
  // The result should be 200 (matched to cheap), not routed to expensive.
1568
1568
  expect(result.status).toBe(200)
1569
1569
  })
1570
+
1571
+ test('rejects no-splits credential replayed at splits route', async () => {
1572
+ // Method whose schema transform moves splits into methodDetails.
1573
+ const splitsMethod = Method.from({
1574
+ name: 'mock',
1575
+ intent: 'charge',
1576
+ schema: {
1577
+ credential: { payload: z.object({ token: z.string() }) },
1578
+ request: z.pipe(
1579
+ z.object({
1580
+ amount: z.string(),
1581
+ currency: z.string(),
1582
+ decimals: z.number(),
1583
+ recipient: z.string(),
1584
+ splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))),
1585
+ }),
1586
+ z.transform(({ amount, currency, decimals, recipient, splits }) => ({
1587
+ methodDetails: {
1588
+ amount: String(Number(amount) * 10 ** decimals),
1589
+ currency,
1590
+ recipient,
1591
+ ...(splits && { splits }),
1592
+ },
1593
+ })),
1594
+ ),
1595
+ },
1596
+ })
1597
+
1598
+ const splitsServerMethod = Method.toServer(splitsMethod, {
1599
+ async verify() {
1600
+ return mockReceipt()
1601
+ },
1602
+ })
1603
+
1604
+ const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey })
1605
+
1606
+ // Get a challenge from a route with no splits
1607
+ const noSplitsHandle = handler.charge({
1608
+ amount: '1',
1609
+ currency: '0x0000000000000000000000000000000000000001',
1610
+ decimals: 6,
1611
+ expires: new Date(Date.now() + 60_000).toISOString(),
1612
+ recipient: '0x0000000000000000000000000000000000000002',
1613
+ })
1614
+ const noSplitsResult = await noSplitsHandle(new Request('https://example.com/no-splits'))
1615
+ expect(noSplitsResult.status).toBe(402)
1616
+ if (noSplitsResult.status !== 402) throw new Error()
1617
+
1618
+ const noSplitsChallenge = Challenge.fromResponse(noSplitsResult.challenge)
1619
+ const credential = Credential.from({
1620
+ challenge: noSplitsChallenge,
1621
+ payload: { token: 'valid' },
1622
+ })
1623
+
1624
+ // Present at a route that requires splits
1625
+ const splitsHandle = handler.charge({
1626
+ amount: '1',
1627
+ currency: '0x0000000000000000000000000000000000000001',
1628
+ decimals: 6,
1629
+ expires: new Date(Date.now() + 60_000).toISOString(),
1630
+ recipient: '0x0000000000000000000000000000000000000002',
1631
+ splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }],
1632
+ })
1633
+ const result = await splitsHandle(
1634
+ new Request('https://example.com/with-splits', {
1635
+ headers: { Authorization: Credential.serialize(credential) },
1636
+ }),
1637
+ )
1638
+
1639
+ expect(result.status).toBe(402)
1640
+ })
1570
1641
  })
1571
1642
 
1572
1643
  describe('withReceipt', () => {
@@ -1741,3 +1812,219 @@ describe('withReceipt', () => {
1741
1812
  server.close()
1742
1813
  })
1743
1814
  })
1815
+
1816
+ describe('realm auto-detection', () => {
1817
+ beforeEach(() => {
1818
+ // Clear all env vars that Env.get('realm') probes so realm falls through to request detection
1819
+ for (const name of [
1820
+ 'MPP_REALM',
1821
+ 'FLY_APP_NAME',
1822
+ 'HEROKU_APP_NAME',
1823
+ 'RAILWAY_PUBLIC_DOMAIN',
1824
+ 'RENDER_EXTERNAL_HOSTNAME',
1825
+ 'VERCEL_URL',
1826
+ 'WEBSITE_HOSTNAME',
1827
+ ])
1828
+ vi.stubEnv(name, '')
1829
+ })
1830
+
1831
+ afterEach(() => {
1832
+ vi.unstubAllEnvs()
1833
+ })
1834
+
1835
+ const mockMethod = Method.toServer(
1836
+ Method.from({
1837
+ name: 'mock',
1838
+ intent: 'charge',
1839
+ schema: {
1840
+ credential: { payload: z.object({ token: z.string() }) },
1841
+ request: z.object({ amount: z.string(), currency: z.string(), recipient: z.string() }),
1842
+ },
1843
+ }),
1844
+ {
1845
+ async verify() {
1846
+ return {
1847
+ method: 'mock',
1848
+ reference: 'ref',
1849
+ status: 'success' as const,
1850
+ timestamp: new Date().toISOString(),
1851
+ }
1852
+ },
1853
+ },
1854
+ )
1855
+
1856
+ test.each([
1857
+ { url: 'https://mpp.dev/resource', expected: 'mpp.dev' },
1858
+ { url: 'https://api.example.com/v1/resource', expected: 'api.example.com' },
1859
+ { url: 'https://localhost:8787/resource', expected: 'localhost' },
1860
+ { url: 'https://MPP.DEV/resource', expected: 'mpp.dev' },
1861
+ { url: 'http://staging.mpp.dev:3000/api', expected: 'staging.mpp.dev' },
1862
+ ])('derives realm "$expected" from $url', async ({ url, expected }) => {
1863
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1864
+
1865
+ const result = await handler.charge({
1866
+ amount: '100',
1867
+ currency: '0x0000000000000000000000000000000000000001',
1868
+ recipient: '0x0000000000000000000000000000000000000002',
1869
+ })(new Request(url))
1870
+
1871
+ expect(result.status).toBe(402)
1872
+ if (result.status !== 402) throw new Error()
1873
+
1874
+ const challenge = Challenge.fromResponse(result.challenge)
1875
+ expect(challenge.realm).toBe(expected)
1876
+ })
1877
+
1878
+ test('credential verifies across different casing of same host', async () => {
1879
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1880
+
1881
+ const chargeOpts = {
1882
+ amount: '100',
1883
+ currency: '0x0000000000000000000000000000000000000001',
1884
+ recipient: '0x0000000000000000000000000000000000000002',
1885
+ }
1886
+
1887
+ // Get challenge with uppercase host
1888
+ const result = await handler.charge(chargeOpts)(new Request('https://MPP.DEV/resource'))
1889
+ expect(result.status).toBe(402)
1890
+ if (result.status !== 402) throw new Error()
1891
+
1892
+ const challenge = Challenge.fromResponse(result.challenge)
1893
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1894
+
1895
+ // Verify with lowercase host — should match since both normalize
1896
+ const verifyResult = await handler.charge(chargeOpts)(
1897
+ new Request('https://mpp.dev/resource', {
1898
+ headers: { Authorization: Credential.serialize(credential) },
1899
+ }),
1900
+ )
1901
+ expect(verifyResult.status).toBe(200)
1902
+ })
1903
+
1904
+ test('explicit realm takes precedence over request url', async () => {
1905
+ const handler = Mppx.create({ methods: [mockMethod], realm: 'explicit.example.com', secretKey })
1906
+
1907
+ const request = new Request('https://other.example.com/resource')
1908
+ const result = await handler.charge({
1909
+ amount: '100',
1910
+ currency: '0x0000000000000000000000000000000000000001',
1911
+ recipient: '0x0000000000000000000000000000000000000002',
1912
+ })(request)
1913
+
1914
+ expect(result.status).toBe(402)
1915
+ if (result.status !== 402) throw new Error()
1916
+
1917
+ const challenge = Challenge.fromResponse(result.challenge)
1918
+ expect(challenge.realm).toBe('explicit.example.com')
1919
+ })
1920
+
1921
+ test('challenge and verification use same auto-detected realm', async () => {
1922
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1923
+
1924
+ const url = 'https://mpp.dev/resource'
1925
+
1926
+ // Get challenge
1927
+ const result = await handler.charge({
1928
+ amount: '100',
1929
+ currency: '0x0000000000000000000000000000000000000001',
1930
+ recipient: '0x0000000000000000000000000000000000000002',
1931
+ })(new Request(url))
1932
+
1933
+ expect(result.status).toBe(402)
1934
+ if (result.status !== 402) throw new Error()
1935
+
1936
+ const challenge = Challenge.fromResponse(result.challenge)
1937
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1938
+
1939
+ // Replay with credential from same host — should verify
1940
+ const verifyResult = await handler.charge({
1941
+ amount: '100',
1942
+ currency: '0x0000000000000000000000000000000000000001',
1943
+ recipient: '0x0000000000000000000000000000000000000002',
1944
+ })(new Request(url, { headers: { Authorization: Credential.serialize(credential) } }))
1945
+
1946
+ expect(verifyResult.status).toBe(200)
1947
+ })
1948
+
1949
+ test('credential from one host rejected at different host', async () => {
1950
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1951
+
1952
+ // Get challenge from host A
1953
+ const result = await handler.charge({
1954
+ amount: '100',
1955
+ currency: '0x0000000000000000000000000000000000000001',
1956
+ recipient: '0x0000000000000000000000000000000000000002',
1957
+ })(new Request('https://host-a.example.com/resource'))
1958
+
1959
+ expect(result.status).toBe(402)
1960
+ if (result.status !== 402) throw new Error()
1961
+
1962
+ const challenge = Challenge.fromResponse(result.challenge)
1963
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1964
+
1965
+ // Present at host B — realm mismatch should reject
1966
+ const verifyResult = await handler.charge({
1967
+ amount: '100',
1968
+ currency: '0x0000000000000000000000000000000000000001',
1969
+ recipient: '0x0000000000000000000000000000000000000002',
1970
+ })(
1971
+ new Request('https://host-b.example.com/resource', {
1972
+ headers: { Authorization: Credential.serialize(credential) },
1973
+ }),
1974
+ )
1975
+
1976
+ expect(verifyResult.status).toBe(402)
1977
+ })
1978
+
1979
+ test('realm undefined on handler when not explicitly set', () => {
1980
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1981
+ expect(handler.realm).toBeUndefined()
1982
+ })
1983
+
1984
+ test('falls back to default realm when input has no url', async () => {
1985
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1986
+ const handle = handler.charge({
1987
+ amount: '100',
1988
+ currency: '0x0000000000000000000000000000000000000001',
1989
+ recipient: '0x0000000000000000000000000000000000000002',
1990
+ })
1991
+
1992
+ // Simulate a non-HTTP input with no .url — should warn and use fallback
1993
+ const result = await handle({} as any)
1994
+ expect(result.status).toBe(402)
1995
+ if (result.status !== 402) throw new Error()
1996
+ const challenge = Challenge.fromResponse(result.challenge)
1997
+ expect(challenge.realm).toBe('MPP Payment')
1998
+ })
1999
+
2000
+ test('cross-host rejection reports realm mismatch', async () => {
2001
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
2002
+
2003
+ const result = await handler.charge({
2004
+ amount: '100',
2005
+ currency: '0x0000000000000000000000000000000000000001',
2006
+ recipient: '0x0000000000000000000000000000000000000002',
2007
+ })(new Request('https://host-a.example.com/resource'))
2008
+
2009
+ expect(result.status).toBe(402)
2010
+ if (result.status !== 402) throw new Error()
2011
+
2012
+ const challenge = Challenge.fromResponse(result.challenge)
2013
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
2014
+
2015
+ const verifyResult = await handler.charge({
2016
+ amount: '100',
2017
+ currency: '0x0000000000000000000000000000000000000001',
2018
+ recipient: '0x0000000000000000000000000000000000000002',
2019
+ })(
2020
+ new Request('https://host-b.example.com/resource', {
2021
+ headers: { Authorization: Credential.serialize(credential) },
2022
+ }),
2023
+ )
2024
+
2025
+ expect(verifyResult.status).toBe(402)
2026
+ if (verifyResult.status !== 402) throw new Error()
2027
+ const body = (await verifyResult.challenge.json()) as { detail: string }
2028
+ expect(body.detail).toContain('realm')
2029
+ })
2030
+ })
@@ -153,7 +153,7 @@ export function create<
153
153
  const transport extends Transport.AnyTransport = Transport.Http,
154
154
  >(config: create.Config<methods, transport>): Mppx<methods, transport> {
155
155
  const {
156
- realm = Env.get('realm') ?? 'MPP Payment',
156
+ realm = Env.get('realm'),
157
157
  secretKey = Env.get('secretKey'),
158
158
  transport = Transport.http() as transport,
159
159
  } = config
@@ -222,7 +222,7 @@ export function create<
222
222
  return {
223
223
  methods,
224
224
  compose: composeFn,
225
- realm: realm as string,
225
+ realm: realm as string | undefined,
226
226
  transport,
227
227
  ...handlers,
228
228
  } as never
@@ -235,7 +235,7 @@ export declare namespace create {
235
235
  > = {
236
236
  /** Array of configured methods. @example [tempo()] */
237
237
  methods: methods
238
- /** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
238
+ /** Server realm (e.g., hostname). Resolution order: explicit value > env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) > request URL hostname > `"MPP Payment"`. */
239
239
  realm?: string | undefined
240
240
  /** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
241
241
  secretKey?: string | undefined
@@ -283,6 +283,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
283
283
  : merged
284
284
  ) as never
285
285
 
286
+ // Resolve realm: explicit > env var > request Host header.
287
+ const effectiveRealm = realm ?? resolveRealmFromRequest(input)
288
+
286
289
  // Recompute challenge from options. The HMAC-bound ID means we don't need to
287
290
  // store challenges server-side—if the client echoes back a credential with
288
291
  // a matching ID, we know it was issued by us with these exact parameters.
@@ -290,7 +293,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
290
293
  description,
291
294
  expires,
292
295
  meta,
293
- realm,
296
+ realm: effectiveRealm,
294
297
  request,
295
298
  secretKey,
296
299
  })
@@ -389,6 +392,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
389
392
  return { challenge: response, status: 402 }
390
393
  }
391
394
  }
395
+
396
+ // Compare payment-relevant methodDetails fields (memo, splits).
397
+ // These are excluded from the top-level field check above but
398
+ // affect verification semantics — a credential issued for a
399
+ // no-splits route must not be accepted on a splits route.
400
+ for (const field of ['memo', 'splits'] as const) {
401
+ const routeVal = routeDetails[field]
402
+ const echoedVal = echoedDetails[field]
403
+ if (
404
+ routeVal !== undefined &&
405
+ JSON.stringify(routeVal) !== JSON.stringify(echoedVal)
406
+ ) {
407
+ const response = await transport.respondChallenge({
408
+ challenge,
409
+ input,
410
+ error: new Errors.InvalidChallengeError({
411
+ id: credential.challenge.id,
412
+ reason: `credential ${field} does not match this route's requirements`,
413
+ }),
414
+ })
415
+ return { challenge: response, status: 402 }
416
+ }
417
+ }
392
418
  }
393
419
  }
394
420
 
@@ -483,7 +509,7 @@ declare namespace createMethodFn {
483
509
  > = {
484
510
  defaults?: defaults
485
511
  method: method
486
- realm: string
512
+ realm: string | undefined
487
513
  request?: Method.RequestFn<method>
488
514
  respond?: Method.RespondFn<method>
489
515
  secretKey: string
@@ -498,6 +524,34 @@ declare namespace createMethodFn {
498
524
  > = MethodFn<method, transport, defaults>
499
525
  }
500
526
 
527
+ const defaultRealm = 'MPP Payment'
528
+ const Warnings = {
529
+ realmFallback: 'realm-fallback',
530
+ } as const
531
+
532
+ const _warned = new Set<string>()
533
+ function warnOnce(key: string, message: string) {
534
+ if (_warned.has(key)) return
535
+ _warned.add(key)
536
+ console.warn(`[mppx] ${message}`)
537
+ }
538
+
539
+ /** Extracts hostname from the request URL, falling back to a default. */
540
+ function resolveRealmFromRequest(input: unknown): string {
541
+ try {
542
+ const url = typeof (input as any)?.url === 'string' ? (input as any).url : undefined
543
+ if (url) {
544
+ const { protocol, hostname } = new URL(url)
545
+ if (/^https?:$/.test(protocol) && hostname) return hostname
546
+ }
547
+ } catch {}
548
+ warnOnce(
549
+ Warnings.realmFallback,
550
+ `Could not auto-detect realm from request. Falling back to "${defaultRealm}". Set \`realm\` in Mppx.create() or the MPP_REALM env var.`,
551
+ )
552
+ return defaultRealm
553
+ }
554
+
501
555
  export type MethodFn<
502
556
  method extends Method.Method,
503
557
  transport extends Transport.AnyTransport,
@@ -51,6 +51,85 @@ describe('charge', () => {
51
51
  expect(result.success).toBe(true)
52
52
  })
53
53
 
54
+ test('schema: validates request with splits', () => {
55
+ const result = Methods.charge.schema.request.safeParse({
56
+ amount: '1',
57
+ currency: '0x20c0000000000000000000000000000000000001',
58
+ decimals: 6,
59
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
60
+ splits: [
61
+ {
62
+ amount: '0.25',
63
+ recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
64
+ },
65
+ {
66
+ amount: '0.1',
67
+ memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
68
+ recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
69
+ },
70
+ ],
71
+ })
72
+ expect(result.success).toBe(true)
73
+ if (!result.success) return
74
+
75
+ expect(result.data.methodDetails?.splits).toEqual([
76
+ {
77
+ amount: '250000',
78
+ recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
79
+ },
80
+ {
81
+ amount: '100000',
82
+ memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
83
+ recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
84
+ },
85
+ ])
86
+ })
87
+
88
+ test('schema: rejects empty splits', () => {
89
+ const result = Methods.charge.schema.request.safeParse({
90
+ amount: '1',
91
+ currency: '0x20c0000000000000000000000000000000000001',
92
+ decimals: 6,
93
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
94
+ splits: [],
95
+ })
96
+ expect(result.success).toBe(false)
97
+ })
98
+
99
+ test('schema: rejects more than 10 splits', () => {
100
+ const result = Methods.charge.schema.request.safeParse({
101
+ amount: '11',
102
+ currency: '0x20c0000000000000000000000000000000000001',
103
+ decimals: 6,
104
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
105
+ splits: Array.from({ length: 11 }, (_, index) => ({
106
+ amount: '0.1',
107
+ recipient: `0x${(index + 1).toString(16).padStart(40, '0')}`,
108
+ })),
109
+ })
110
+ expect(result.success).toBe(false)
111
+ })
112
+
113
+ test('schema: rejects split totals greater than or equal to amount', () => {
114
+ const result = Methods.charge.schema.request.safeParse({
115
+ amount: '1',
116
+ currency: '0x20c0000000000000000000000000000000000001',
117
+ decimals: 6,
118
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
119
+ splits: [
120
+ {
121
+ amount: '0.5',
122
+ recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
123
+ },
124
+ {
125
+ amount: '0.5',
126
+ recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
127
+ },
128
+ ],
129
+ })
130
+ expect(result.success).toBe(false)
131
+ })
132
+
54
133
  test('schema: rejects invalid request', () => {
55
134
  const result = Methods.charge.schema.request.safeParse({
56
135
  amount: '1',
@@ -1,9 +1,18 @@
1
- import type { Account } from 'viem'
1
+ import type { Account, Address } from 'viem'
2
2
  import { parseUnits } from 'viem'
3
3
 
4
4
  import * as Method from '../Method.js'
5
5
  import * as z from '../zod.js'
6
6
 
7
+ const split = z.object({
8
+ amount: z.amount(),
9
+ memo: z.optional(z.hash()),
10
+ recipient: z.pipe(
11
+ z.string(),
12
+ z.transform((v) => v as Address),
13
+ ),
14
+ })
15
+
7
16
  /**
8
17
  * Tempo charge intent for one-time TIP-20 token transfers.
9
18
  *
@@ -20,31 +29,58 @@ export const charge = Method.from({
20
29
  ]),
21
30
  },
22
31
  request: z.pipe(
23
- z.object({
24
- amount: z.amount(),
25
- chainId: z.optional(z.number()),
26
- currency: z.string(),
27
- decimals: z.number(),
28
- description: z.optional(z.string()),
29
- externalId: z.optional(z.string()),
30
- feePayer: z.optional(
31
- z.pipe(
32
- z.union([z.boolean(), z.custom<Account>()]),
33
- z.transform((v): boolean => (typeof v === 'object' ? true : v)),
32
+ z
33
+ .object({
34
+ amount: z.amount(),
35
+ chainId: z.optional(z.number()),
36
+ currency: z.string(),
37
+ decimals: z.number(),
38
+ description: z.optional(z.string()),
39
+ externalId: z.optional(z.string()),
40
+ feePayer: z.optional(
41
+ z.pipe(
42
+ z.union([z.boolean(), z.custom<Account>()]),
43
+ z.transform((v): boolean => (typeof v === 'object' ? true : v)),
44
+ ),
34
45
  ),
46
+ memo: z.optional(z.hash()),
47
+ recipient: z.optional(z.string()),
48
+ splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(10))),
49
+ })
50
+ .check(
51
+ z.refine(({ amount, decimals, splits }) => {
52
+ if (!splits) return true
53
+
54
+ const totalAmount = parseUnits(amount, decimals)
55
+ const splitTotal = splits.reduce(
56
+ (sum, split) => sum + parseUnits(split.amount, decimals),
57
+ 0n,
58
+ )
59
+
60
+ return (
61
+ splits.every((split) => parseUnits(split.amount, decimals) > 0n) &&
62
+ splitTotal < totalAmount
63
+ )
64
+ }, 'Invalid splits'),
35
65
  ),
36
- memo: z.optional(z.hash()),
37
- recipient: z.optional(z.string()),
38
- }),
39
- z.transform(({ amount, chainId, decimals, feePayer, memo, ...rest }) => ({
66
+ z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
40
67
  ...rest,
41
68
  amount: parseUnits(amount, decimals).toString(),
42
- ...(chainId !== undefined || feePayer !== undefined || memo !== undefined
69
+ ...(chainId !== undefined ||
70
+ feePayer !== undefined ||
71
+ memo !== undefined ||
72
+ splits !== undefined
43
73
  ? {
44
74
  methodDetails: {
45
75
  ...(chainId !== undefined && { chainId }),
46
76
  ...(feePayer !== undefined && { feePayer }),
47
77
  ...(memo !== undefined && { memo }),
78
+ ...(splits !== undefined && {
79
+ splits: splits.map((split) => ({
80
+ ...split,
81
+ amount: parseUnits(split.amount, decimals).toString(),
82
+ })),
83
+ }),
48
84
  },
49
85
  }
50
86
  : {}),
@@ -11,6 +11,7 @@ import * as Client from '../../viem/Client.js'
11
11
  import * as z from '../../zod.js'
12
12
  import * as Attribution from '../Attribution.js'
13
13
  import * as AutoSwap from '../internal/auto-swap.js'
14
+ import * as Charge_internal from '../internal/charge.js'
14
15
  import * as defaults from '../internal/defaults.js'
15
16
  import * as Methods from '../Methods.js'
16
17
 
@@ -54,18 +55,37 @@ export function charge(parameters: charge.Parameters = {}) {
54
55
  const { request } = challenge
55
56
  const { amount, methodDetails } = request
56
57
  const currency = request.currency as Address
57
- const recipient = request.recipient as Address
58
+
59
+ if (parameters.expectedRecipients) {
60
+ const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase()))
61
+ const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined
62
+ if (splits) {
63
+ for (const split of splits) {
64
+ if (!allowed.has(split.recipient.toLowerCase()))
65
+ throw new Error(`Unexpected split recipient: ${split.recipient}`)
66
+ }
67
+ }
68
+ }
58
69
 
59
70
  const memo = methodDetails?.memo
60
71
  ? (methodDetails.memo as Hex.Hex)
61
72
  : Attribution.encode({ serverId: challenge.realm, clientId })
62
-
63
- const transferCall = Actions.token.transfer.call({
64
- amount: BigInt(amount),
65
- memo,
66
- to: recipient,
67
- token: currency,
73
+ const transfers = Charge_internal.getTransfers({
74
+ amount,
75
+ methodDetails: {
76
+ ...methodDetails,
77
+ memo,
78
+ },
79
+ recipient: request.recipient as Address,
68
80
  })
81
+ const transferCalls = transfers.map((transfer) =>
82
+ Actions.token.transfer.call({
83
+ amount: BigInt(transfer.amount),
84
+ ...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
85
+ to: transfer.recipient as Address,
86
+ token: currency,
87
+ }),
88
+ )
69
89
 
70
90
  const autoSwap = AutoSwap.resolve(
71
91
  context?.autoSwap ?? parameters.autoSwap,
@@ -82,7 +102,14 @@ export function charge(parameters: charge.Parameters = {}) {
82
102
  })
83
103
  : undefined
84
104
 
85
- const calls = [...(swapCalls ?? []), transferCall]
105
+ const calls = [...(swapCalls ?? []), ...transferCalls]
106
+
107
+ const validBefore = (() => {
108
+ const defaultExpiry = Math.floor(Date.now() / 1000) + 25
109
+ if (!challenge.expires) return defaultExpiry
110
+ const challengeExpiry = Math.floor(new Date(challenge.expires).getTime() / 1000)
111
+ return Math.min(defaultExpiry, challengeExpiry)
112
+ })()
86
113
 
87
114
  if (mode === 'push') {
88
115
  const { receipts } = await sendCallsSync(client, {
@@ -104,6 +131,7 @@ export function charge(parameters: charge.Parameters = {}) {
104
131
  calls,
105
132
  ...(methodDetails?.feePayer && { feePayer: true }),
106
133
  nonceKey: 'expiring',
134
+ validBefore,
107
135
  } as never)
108
136
  // FIXME: figure out gas estimation issue for fee payer tx
109
137
  prepared.gas = prepared.gas! + 5_000n
@@ -131,6 +159,11 @@ export declare namespace charge {
131
159
  autoSwap?: AutoSwap | undefined
132
160
  /** Client identifier used to derive the client fingerprint in attribution memos. */
133
161
  clientId?: string | undefined
162
+ /**
163
+ * Allowlist of expected split recipient addresses. When set, the client
164
+ * rejects any challenge whose split recipients are not in this list.
165
+ */
166
+ expectedRecipients?: readonly Address[] | undefined
134
167
  /**
135
168
  * Controls how the charge transaction is submitted.
136
169
  *