mppx 0.4.7 → 0.4.8
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 +6 -0
- package/dist/Store.d.ts +5 -4
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +22 -7
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +9 -22
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +5 -1
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +3 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts +2 -2
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +4 -2
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +26 -8
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/internal/address.d.ts +3 -0
- package/dist/tempo/internal/address.d.ts.map +1 -0
- package/dist/tempo/internal/address.js +4 -0
- package/dist/tempo/internal/address.js.map +1 -0
- package/dist/tempo/internal/auto-swap.js +3 -3
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +11 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +11 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +109 -50
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +31 -23
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +41 -1
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +51 -10
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +4 -2
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Voucher.d.ts.map +1 -1
- package/dist/tempo/session/Voucher.js +3 -2
- package/dist/tempo/session/Voucher.js.map +1 -1
- package/package.json +2 -2
- package/src/Store.test-d.ts +58 -0
- package/src/Store.ts +6 -4
- package/src/cli/cli.test.ts +124 -0
- package/src/cli/cli.ts +19 -7
- package/src/cli/plugins/tempo.ts +17 -23
- package/src/middlewares/elysia.test.ts +89 -0
- package/src/middlewares/elysia.ts +4 -1
- package/src/proxy/Proxy.test.ts +56 -0
- package/src/proxy/Proxy.ts +6 -1
- package/src/proxy/internal/Route.test.ts +57 -0
- package/src/proxy/internal/Route.ts +3 -1
- package/src/server/Mppx.test.ts +246 -0
- package/src/server/Mppx.ts +27 -8
- package/src/tempo/internal/address.ts +6 -0
- package/src/tempo/internal/auto-swap.ts +3 -3
- package/src/tempo/internal/fee-payer.ts +18 -4
- package/src/tempo/server/Charge.test.ts +1080 -31
- package/src/tempo/server/Charge.ts +158 -63
- package/src/tempo/server/Session.test.ts +896 -108
- package/src/tempo/server/Session.ts +40 -23
- package/src/tempo/server/Sse.test.ts +1 -0
- package/src/tempo/server/internal/transport.test.ts +29 -0
- package/src/tempo/server/internal/transport.ts +41 -2
- package/src/tempo/session/Chain.test.ts +144 -0
- package/src/tempo/session/Chain.ts +58 -10
- package/src/tempo/session/ChannelStore.test.ts +10 -0
- package/src/tempo/session/ChannelStore.ts +6 -3
- package/src/tempo/session/Sse.test.ts +1 -0
- package/src/tempo/session/Voucher.ts +3 -2
package/src/server/Mppx.test.ts
CHANGED
|
@@ -1130,6 +1130,252 @@ describe('nested accessors', () => {
|
|
|
1130
1130
|
})
|
|
1131
1131
|
})
|
|
1132
1132
|
|
|
1133
|
+
describe('cross-route credential replay via scope binding flaw', () => {
|
|
1134
|
+
// Method whose schema transform moves `amount`, `currency`, and `recipient`
|
|
1135
|
+
// into `methodDetails`, removing them from the top-level request. This mirrors
|
|
1136
|
+
// real-world methods (e.g. Tempo) that restructure fields via z.transform.
|
|
1137
|
+
const transformingMethod = Method.from({
|
|
1138
|
+
name: 'mock',
|
|
1139
|
+
intent: 'charge',
|
|
1140
|
+
schema: {
|
|
1141
|
+
credential: {
|
|
1142
|
+
payload: z.object({ token: z.string() }),
|
|
1143
|
+
},
|
|
1144
|
+
request: z.pipe(
|
|
1145
|
+
z.object({
|
|
1146
|
+
amount: z.string(),
|
|
1147
|
+
currency: z.string(),
|
|
1148
|
+
decimals: z.number(),
|
|
1149
|
+
recipient: z.string(),
|
|
1150
|
+
}),
|
|
1151
|
+
z.transform(({ amount, currency, decimals, recipient }) => ({
|
|
1152
|
+
methodDetails: { amount: String(Number(amount) * 10 ** decimals), currency, recipient },
|
|
1153
|
+
})),
|
|
1154
|
+
),
|
|
1155
|
+
},
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
function mockReceipt() {
|
|
1159
|
+
return {
|
|
1160
|
+
method: 'mock',
|
|
1161
|
+
reference: 'tx-ref',
|
|
1162
|
+
status: 'success' as const,
|
|
1163
|
+
timestamp: new Date().toISOString(),
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const serverMethod = Method.toServer(transformingMethod, {
|
|
1168
|
+
async verify() {
|
|
1169
|
+
return mockReceipt()
|
|
1170
|
+
},
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
test('rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
|
|
1174
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
|
|
1175
|
+
|
|
1176
|
+
// Get a challenge from the "cheap" route ($0.01)
|
|
1177
|
+
const cheapHandle = handler.charge({
|
|
1178
|
+
amount: '0.01',
|
|
1179
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1180
|
+
decimals: 6,
|
|
1181
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1182
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1183
|
+
})
|
|
1184
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
|
|
1185
|
+
expect(cheapResult.status).toBe(402)
|
|
1186
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
1187
|
+
|
|
1188
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
1189
|
+
|
|
1190
|
+
// Build a credential from the cheap challenge
|
|
1191
|
+
const credential = Credential.from({
|
|
1192
|
+
challenge: cheapChallenge,
|
|
1193
|
+
payload: { token: 'valid' },
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
// Present the cheap credential at the "expensive" route ($100)
|
|
1197
|
+
const expensiveHandle = handler.charge({
|
|
1198
|
+
amount: '100',
|
|
1199
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1200
|
+
decimals: 6,
|
|
1201
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1202
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1203
|
+
})
|
|
1204
|
+
const result = await expensiveHandle(
|
|
1205
|
+
new Request('https://example.com/expensive', {
|
|
1206
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1207
|
+
}),
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
// Should be 402 (credential was for $0.01, not $100)
|
|
1211
|
+
expect(result.status).toBe(402)
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
test('rejects credential with mismatched method field', async () => {
|
|
1215
|
+
const otherMethod = Method.from({
|
|
1216
|
+
name: 'other',
|
|
1217
|
+
intent: 'charge',
|
|
1218
|
+
schema: {
|
|
1219
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1220
|
+
request: z.object({
|
|
1221
|
+
amount: z.string(),
|
|
1222
|
+
currency: z.string(),
|
|
1223
|
+
decimals: z.number(),
|
|
1224
|
+
recipient: z.string(),
|
|
1225
|
+
}),
|
|
1226
|
+
},
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
const otherServerMethod = Method.toServer(otherMethod, {
|
|
1230
|
+
async verify() {
|
|
1231
|
+
return mockReceipt()
|
|
1232
|
+
},
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
const handler = Mppx.create({ methods: [serverMethod, otherServerMethod], realm, secretKey })
|
|
1236
|
+
|
|
1237
|
+
// Get challenge from mock/charge
|
|
1238
|
+
const mockHandle = handler['mock/charge']({
|
|
1239
|
+
amount: '1',
|
|
1240
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1241
|
+
decimals: 6,
|
|
1242
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1243
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1244
|
+
})
|
|
1245
|
+
const mockResult = await mockHandle(new Request('https://example.com/mock'))
|
|
1246
|
+
expect(mockResult.status).toBe(402)
|
|
1247
|
+
if (mockResult.status !== 402) throw new Error()
|
|
1248
|
+
|
|
1249
|
+
const mockChallenge = Challenge.fromResponse(mockResult.challenge)
|
|
1250
|
+
const credential = Credential.from({
|
|
1251
|
+
challenge: mockChallenge,
|
|
1252
|
+
payload: { token: 'valid' },
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
// Present mock/charge credential at other/charge route
|
|
1256
|
+
const otherHandle = handler['other/charge']({
|
|
1257
|
+
amount: '1',
|
|
1258
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1259
|
+
decimals: 6,
|
|
1260
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1261
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1262
|
+
})
|
|
1263
|
+
const result = await otherHandle(
|
|
1264
|
+
new Request('https://example.com/other', {
|
|
1265
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1266
|
+
}),
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
// Should reject (credential was for method "mock", not "other")
|
|
1270
|
+
expect(result.status).toBe(402)
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
test('rejects credential with mismatched intent field', async () => {
|
|
1274
|
+
const sessionMethod = Method.from({
|
|
1275
|
+
name: 'mock',
|
|
1276
|
+
intent: 'session',
|
|
1277
|
+
schema: {
|
|
1278
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1279
|
+
request: z.object({
|
|
1280
|
+
amount: z.string(),
|
|
1281
|
+
currency: z.string(),
|
|
1282
|
+
decimals: z.number(),
|
|
1283
|
+
recipient: z.string(),
|
|
1284
|
+
}),
|
|
1285
|
+
},
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
const sessionServerMethod = Method.toServer(sessionMethod, {
|
|
1289
|
+
async verify() {
|
|
1290
|
+
return mockReceipt()
|
|
1291
|
+
},
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
const handler = Mppx.create({ methods: [serverMethod, sessionServerMethod], realm, secretKey })
|
|
1295
|
+
|
|
1296
|
+
// Get challenge from mock/charge
|
|
1297
|
+
const chargeHandle = handler['mock/charge']({
|
|
1298
|
+
amount: '1',
|
|
1299
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1300
|
+
decimals: 6,
|
|
1301
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1302
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1303
|
+
})
|
|
1304
|
+
const chargeResult = await chargeHandle(new Request('https://example.com/charge'))
|
|
1305
|
+
expect(chargeResult.status).toBe(402)
|
|
1306
|
+
if (chargeResult.status !== 402) throw new Error()
|
|
1307
|
+
|
|
1308
|
+
const chargeChallenge = Challenge.fromResponse(chargeResult.challenge)
|
|
1309
|
+
const credential = Credential.from({
|
|
1310
|
+
challenge: chargeChallenge,
|
|
1311
|
+
payload: { token: 'valid' },
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
// Present charge credential at session route
|
|
1315
|
+
const sessionHandle = handler['mock/session']({
|
|
1316
|
+
amount: '1',
|
|
1317
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1318
|
+
decimals: 6,
|
|
1319
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1320
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1321
|
+
})
|
|
1322
|
+
const result = await sessionHandle(
|
|
1323
|
+
new Request('https://example.com/session', {
|
|
1324
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1325
|
+
}),
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
// Should reject (credential was for intent "charge", not "session")
|
|
1329
|
+
expect(result.status).toBe(402)
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
test('compose: rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
|
|
1333
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
|
|
1334
|
+
|
|
1335
|
+
const cheapHandle = handler.charge({
|
|
1336
|
+
amount: '0.01',
|
|
1337
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1338
|
+
decimals: 6,
|
|
1339
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1340
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1341
|
+
})
|
|
1342
|
+
const expensiveHandle = handler.charge({
|
|
1343
|
+
amount: '100',
|
|
1344
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1345
|
+
decimals: 6,
|
|
1346
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1347
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
const composed = Mppx.compose(cheapHandle, expensiveHandle)
|
|
1351
|
+
|
|
1352
|
+
// Get challenge (pick the cheap one)
|
|
1353
|
+
const firstResult = await composed(new Request('https://example.com/resource'))
|
|
1354
|
+
expect(firstResult.status).toBe(402)
|
|
1355
|
+
if (firstResult.status !== 402) throw new Error()
|
|
1356
|
+
|
|
1357
|
+
const challenges = Challenge.fromResponseList(firstResult.challenge)
|
|
1358
|
+
const cheapChallenge = challenges[0]!
|
|
1359
|
+
|
|
1360
|
+
const credential = Credential.from({
|
|
1361
|
+
challenge: cheapChallenge,
|
|
1362
|
+
payload: { token: 'valid' },
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
// The composed handler should NOT route the cheap credential to the expensive handler
|
|
1366
|
+
const result = await composed(
|
|
1367
|
+
new Request('https://example.com/resource', {
|
|
1368
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1369
|
+
}),
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
// If scope binding works, the credential should route to the cheap handler only.
|
|
1373
|
+
// It should NOT match the expensive handler's canonical request.
|
|
1374
|
+
// The result should be 200 (matched to cheap), not routed to expensive.
|
|
1375
|
+
expect(result.status).toBe(200)
|
|
1376
|
+
})
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1133
1379
|
describe('withReceipt', () => {
|
|
1134
1380
|
const mockCharge = Method.from({
|
|
1135
1381
|
name: 'mock',
|
package/src/server/Mppx.ts
CHANGED
|
@@ -329,19 +329,36 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
// Verify the credential's challenge matches this route's configured
|
|
332
|
-
// request. Prevents cross-route scope
|
|
333
|
-
// issued for a cheap route
|
|
332
|
+
// method, intent, realm, and request. Prevents cross-route scope
|
|
333
|
+
// confusion where a credential issued for a cheap route (or different
|
|
334
|
+
// method/intent) is presented at an expensive route.
|
|
334
335
|
// Note: we compare specific payment parameters rather than the full
|
|
335
336
|
// request because the `request` hook may produce credential-dependent
|
|
336
337
|
// output (e.g. `feePayer` differs between 402 and credential calls).
|
|
337
338
|
{
|
|
339
|
+
for (const field of ['method', 'intent', 'realm'] as const) {
|
|
340
|
+
if (credential.challenge[field] !== challenge[field]) {
|
|
341
|
+
const response = await transport.respondChallenge({
|
|
342
|
+
challenge,
|
|
343
|
+
input,
|
|
344
|
+
error: new Errors.InvalidChallengeError({
|
|
345
|
+
id: credential.challenge.id,
|
|
346
|
+
reason: `credential ${field} does not match this route's requirements`,
|
|
347
|
+
}),
|
|
348
|
+
})
|
|
349
|
+
return { challenge: response, status: 402 }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
338
353
|
const routeReq = challenge.request as Record<string, unknown>
|
|
339
354
|
const echoedReq = credential.challenge.request as Record<string, unknown>
|
|
355
|
+
const routeDetails = (routeReq.methodDetails ?? {}) as Record<string, unknown>
|
|
356
|
+
const echoedDetails = (echoedReq.methodDetails ?? {}) as Record<string, unknown>
|
|
340
357
|
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
358
|
+
const routeVal = routeReq[field] ?? routeDetails[field]
|
|
341
359
|
if (
|
|
342
|
-
|
|
343
|
-
echoedReq[field]
|
|
344
|
-
String(routeReq[field]) !== String(echoedReq[field])
|
|
360
|
+
routeVal !== undefined &&
|
|
361
|
+
String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
|
|
345
362
|
) {
|
|
346
363
|
const response = await transport.respondChallenge({
|
|
347
364
|
challenge,
|
|
@@ -578,22 +595,24 @@ export function compose(
|
|
|
578
595
|
if (credential) {
|
|
579
596
|
const { method: credMethod, intent: credIntent } = credential.challenge
|
|
580
597
|
const credReq = credential.challenge.request as Record<string, unknown>
|
|
598
|
+
const credDetails = (credReq.methodDetails ?? {}) as Record<string, unknown>
|
|
581
599
|
|
|
582
600
|
// Filter by name+intent, then narrow by comparing stable request fields
|
|
583
601
|
// from the echoed challenge against each handler's canonical request.
|
|
584
602
|
// Uses the schema-parsed canonical form (not raw options) so that
|
|
585
603
|
// transformed fields (e.g. amount with decimals) match correctly.
|
|
604
|
+
// Also checks inside methodDetails for fields moved there by transforms.
|
|
586
605
|
const candidates = handlers.filter((h) => {
|
|
587
606
|
const meta = (h as ConfiguredHandler)._internal
|
|
588
607
|
if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
|
|
589
608
|
const canonical = meta._canonicalRequest
|
|
590
609
|
if (!canonical) return true
|
|
610
|
+
const canonicalDetails = (canonical.methodDetails ?? {}) as Record<string, unknown>
|
|
591
611
|
for (const field of ['amount', 'currency', 'recipient', 'chainId'] as const) {
|
|
592
|
-
const canonicalVal = canonical[field]
|
|
612
|
+
const canonicalVal = canonical[field] ?? canonicalDetails[field]
|
|
593
613
|
if (
|
|
594
614
|
canonicalVal !== undefined &&
|
|
595
|
-
credReq[field]
|
|
596
|
-
String(canonicalVal) !== String(credReq[field])
|
|
615
|
+
String(canonicalVal) !== String(credReq[field] ?? credDetails[field])
|
|
597
616
|
)
|
|
598
617
|
return false
|
|
599
618
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Address, Client } from 'viem'
|
|
2
|
-
import { isAddressEqual } from 'viem'
|
|
3
2
|
import { readContract } from 'viem/actions'
|
|
4
3
|
import { Actions, Addresses } from 'viem/tempo'
|
|
4
|
+
import * as TempoAddress from './address.js'
|
|
5
5
|
import * as defaults from './defaults.js'
|
|
6
6
|
|
|
7
7
|
/** Basis-point denominator (100% = 10 000 bps). */
|
|
@@ -26,7 +26,7 @@ export async function findCalls(
|
|
|
26
26
|
): Promise<findCalls.ReturnType> {
|
|
27
27
|
const { account, amountOut, tokenOut, tokenIn, slippage } = parameters
|
|
28
28
|
|
|
29
|
-
const candidates = tokenIn.filter((t) => !
|
|
29
|
+
const candidates = tokenIn.filter((t) => !TempoAddress.isEqual(t, tokenOut))
|
|
30
30
|
|
|
31
31
|
const balanceResults = await Promise.allSettled([
|
|
32
32
|
readContract(client, Actions.token.getBalance.call({ account, token: tokenOut }) as never),
|
|
@@ -108,7 +108,7 @@ export function resolve(
|
|
|
108
108
|
const tokenIn = value.tokenIn
|
|
109
109
|
? [
|
|
110
110
|
...value.tokenIn,
|
|
111
|
-
...defaultCurrencies.filter((d) => !value.tokenIn!.some((c) =>
|
|
111
|
+
...defaultCurrencies.filter((d) => !value.tokenIn!.some((c) => TempoAddress.isEqual(c, d))),
|
|
112
112
|
]
|
|
113
113
|
: defaultCurrencies
|
|
114
114
|
return {
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { TempoAddress } from 'ox/tempo'
|
|
2
|
+
import { TxEnvelopeTempo } from 'ox/tempo'
|
|
3
|
+
import { decodeFunctionData } from 'viem'
|
|
2
4
|
import { Abis, Addresses } from 'viem/tempo'
|
|
5
|
+
import * as TempoAddress_internal from './address.js'
|
|
3
6
|
import * as Selectors from './selectors.js'
|
|
4
7
|
|
|
8
|
+
/** Returns true if the serialized transaction has a Tempo envelope prefix. */
|
|
9
|
+
export function isTempoTransaction(serialized: string | undefined): boolean {
|
|
10
|
+
return (
|
|
11
|
+
serialized?.startsWith(TxEnvelopeTempo.serializedType) === true ||
|
|
12
|
+
serialized?.startsWith(TxEnvelopeTempo.feePayerMagic) === true
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
/**
|
|
6
17
|
* Allowed call patterns for fee-payer sponsored transactions.
|
|
7
18
|
* Each inner array is an ordered list of function selectors.
|
|
@@ -15,7 +26,7 @@ export const callScopes = [
|
|
|
15
26
|
|
|
16
27
|
/** Validates that a set of transaction calls matches an allowed fee-payer pattern. */
|
|
17
28
|
export function validateCalls(
|
|
18
|
-
calls: readonly { data?: `0x${string}` | undefined; to?:
|
|
29
|
+
calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[],
|
|
19
30
|
details: Record<string, string>,
|
|
20
31
|
) {
|
|
21
32
|
const callSelectors = calls.map((c) => c.data?.slice(0, 10))
|
|
@@ -31,11 +42,14 @@ export function validateCalls(
|
|
|
31
42
|
const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve)
|
|
32
43
|
if (approveCall) {
|
|
33
44
|
const { args } = decodeFunctionData({ abi: Abis.tip20, data: approveCall.data! })
|
|
34
|
-
if (!
|
|
45
|
+
if (!TempoAddress_internal.isEqual((args as [`0x${string}`])[0]!, Addresses.stablecoinDex))
|
|
35
46
|
throw new FeePayerValidationError('approve spender is not the DEX', details)
|
|
36
47
|
}
|
|
37
48
|
const buyCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.swapExactAmountOut)
|
|
38
|
-
if (
|
|
49
|
+
if (
|
|
50
|
+
buyCall &&
|
|
51
|
+
(!buyCall.to || !TempoAddress_internal.isEqual(buyCall.to, Addresses.stablecoinDex))
|
|
52
|
+
)
|
|
39
53
|
throw new FeePayerValidationError('buy target is not the DEX', details)
|
|
40
54
|
}
|
|
41
55
|
|