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.
- package/CHANGELOG.md +15 -0
- package/dist/internal/env.d.ts +1 -1
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +2 -6
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/types.d.ts +23 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +49 -2
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/Methods.d.ts +15 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +27 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +21 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +33 -7
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +15 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/account.d.ts +5 -11
- package/dist/tempo/internal/account.d.ts.map +1 -1
- package/dist/tempo/internal/charge.d.ts +20 -0
- package/dist/tempo/internal/charge.d.ts.map +1 -0
- package/dist/tempo/internal/charge.js +23 -0
- package/dist/tempo/internal/charge.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +15 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +17 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +148 -99
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +17 -2
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +9 -4
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -3
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +18 -2
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +18 -14
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/env.test.ts +12 -12
- package/src/internal/env.ts +2 -6
- package/src/internal/types.ts +25 -0
- package/src/server/Mppx.test.ts +287 -0
- package/src/server/Mppx.ts +59 -5
- package/src/tempo/Methods.test.ts +79 -0
- package/src/tempo/Methods.ts +53 -17
- package/src/tempo/client/Charge.ts +41 -8
- package/src/tempo/internal/account.ts +7 -14
- package/src/tempo/internal/charge.ts +43 -0
- package/src/tempo/internal/fee-payer.test.ts +33 -14
- package/src/tempo/internal/fee-payer.ts +21 -6
- package/src/tempo/server/Charge.test.ts +231 -0
- package/src/tempo/server/Charge.ts +193 -124
- package/src/tempo/server/Methods.ts +4 -1
- package/src/tempo/server/Session.test.ts +28 -0
- package/src/tempo/server/Session.ts +26 -17
- package/src/tempo/session/Chain.test.ts +25 -5
- package/src/tempo/session/Chain.ts +30 -14
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/server/Mppx.ts
CHANGED
|
@@ -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')
|
|
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).
|
|
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',
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
z.
|
|
32
|
-
z.
|
|
33
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 ?? []),
|
|
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
|
*
|