diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d767f2f4..2e8915e1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,4 +13,5 @@ /packages/delegation-core @MetaMask/delegation /packages/delegation-deployments @MetaMask/delegation /packages/smart-accounts-kit @MetaMask/delegation +/packages/x402 @MetaMask/delegation /packages/7715-permission-types @MetaMask/delegation diff --git a/packages/x402/CHANGELOG.md b/packages/x402/CHANGELOG.md new file mode 100644 index 00000000..c10aef61 --- /dev/null +++ b/packages/x402/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- New @metamask/x402 package providing plugins to @x402 packages ([#236](https://github.com/MetaMask/smart-accounts-kit/pull/236)) + +[Unreleased]: https://github.com/metamask/smart-accounts-kit/ diff --git a/packages/x402/LICENSE.APACHE2 b/packages/x402/LICENSE.APACHE2 new file mode 100644 index 00000000..49966a71 --- /dev/null +++ b/packages/x402/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 ConsenSys Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/x402/LICENSE.MIT0 b/packages/x402/LICENSE.MIT0 new file mode 100644 index 00000000..74e1d3df --- /dev/null +++ b/packages/x402/LICENSE.MIT0 @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright 2022 ConsenSys Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/x402/README.md b/packages/x402/README.md new file mode 100644 index 00000000..e4dbb775 --- /dev/null +++ b/packages/x402/README.md @@ -0,0 +1,21 @@ +# @metamask/x402 + +x402 adapters for ERC-7710 payment requirement publishing and payload creation. + +## Installation + +```bash +yarn add @metamask/x402 +npm install @metamask/x402 +``` + +## Exports + +- `x402Erc7710Client` +- `x402Erc7710Server` +- `x402ExactEvmErc7710ServerScheme` + +## Notes + +This package intentionally does not depend on `@metamask/smart-accounts-kit`. +Consumers provide delegation payloads via `x402DelegationProvider`. diff --git a/packages/x402/eslint.config.mjs b/packages/x402/eslint.config.mjs new file mode 100644 index 00000000..b80d3437 --- /dev/null +++ b/packages/x402/eslint.config.mjs @@ -0,0 +1,52 @@ +// eslint-disable-next-line +import baseConfig from '../../shared/config/base.eslint.mjs'; + +const withX402NamingExceptions = baseConfig.map((entry) => { + const namingConventionRule = + entry.rules?.['@typescript-eslint/naming-convention']; + + if (!Array.isArray(namingConventionRule)) { + return entry; + } + + const [level, ...conventions] = namingConventionRule; + + return { + ...entry, + rules: { + ...entry.rules, + '@typescript-eslint/naming-convention': [ + level, + { + selector: ['class', 'typeAlias'], + filter: { + regex: '^x402[A-Z].*$', + match: true, + }, + format: null, + }, + ...conventions, + ], + }, + }; +}); + +const config = [ + ...withX402NamingExceptions, + { + files: ['**/*.ts', '**/*.tsx'], + rules: { + 'new-cap': [ + 'error', + { + newIsCap: true, + newIsCapExceptionPattern: '^x402[A-Z]', + capIsNew: true, + properties: true, + }, + ], + }, + }, +]; + +export default config; diff --git a/packages/x402/package.json b/packages/x402/package.json new file mode 100644 index 00000000..0ad24c9f --- /dev/null +++ b/packages/x402/package.json @@ -0,0 +1,74 @@ +{ + "name": "@metamask/x402", + "version": "0.0.0", + "description": "x402 adapters for MetaMask smart accounts and ERC-7710", + "license": "(MIT-0 OR Apache-2.0)", + "type": "module", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/metamask/smart-accounts-kit/tree/main/packages/x402#readme", + "bugs": { + "url": "https://github.com/metamask/smart-accounts-kit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/metamask/smart-accounts-kit.git" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "dist/" + ], + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "./package.json": "./package.json" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "sideEffects": false, + "scripts": { + "build": "yarn typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:watch": "vitest watch", + "lint": "yarn lint:eslint", + "lint:eslint": "eslint . --cache --ext js,ts", + "lint:fix": "yarn lint:eslint --fix", + "changelog:update": "../../scripts/update-changelog.sh @metamask/x402", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/x402" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "peerDependencies": { + "@x402/core": "^2.12.0", + "@x402/evm": "^2.12.0", + "viem": "^2.31.4" + }, + "devDependencies": { + "@metamask/auto-changelog": "^5.0.2", + "@x402/core": "^2.12.0", + "@x402/evm": "^2.12.0", + "eslint": "^9.39.2", + "prettier": "^3.5.3", + "tsup": "^8.5.0", + "typescript": "5.5.4", + "viem": "2.31.4", + "vitest": "^3.2.4" + } +} diff --git a/packages/x402/src/index.ts b/packages/x402/src/index.ts new file mode 100644 index 00000000..03c9dfb3 --- /dev/null +++ b/packages/x402/src/index.ts @@ -0,0 +1,11 @@ +export { + x402Erc7710Client, + type x402DelegationProvider, + type x402DelegationPaymentPayload, + type x402PaymentRequirements, + type x402PaymentPayloadResult, + type x402SchemeNetworkClientLike, + type x402Erc7710ClientConfig, +} from './x402Client'; +export { x402Erc7710Server, type x402Erc7710ServerConfig } from './x402Server'; +export { x402ExactEvmErc7710ServerScheme } from './x402ExactEvmErc7710ServerScheme'; diff --git a/packages/x402/src/x402Client.ts b/packages/x402/src/x402Client.ts new file mode 100644 index 00000000..c2b0b02d --- /dev/null +++ b/packages/x402/src/x402Client.ts @@ -0,0 +1,116 @@ +import { type Hex, getAddress, isHex } from 'viem'; + +export type x402PaymentRequirements = { + scheme: string; + network: string; + asset: string; + amount: string; + payTo: string; + maxTimeoutSeconds: number; + extra?: Record; +}; + +export type x402PaymentPayloadResult = { + x402Version: number; + payload: Record; + extensions?: Record; +}; + +export type x402DelegationPaymentPayload = { + delegationManager: Hex; + permissionContext: Hex; + delegator: Hex; +}; + +export type x402DelegationProvider = ( + paymentRequirements: x402PaymentRequirements, +) => Promise; + +export type x402SchemeNetworkClientLike = { + readonly scheme: string; + createPaymentPayload: ( + x402Version: number, + paymentRequirements: x402PaymentRequirements, + context?: Record, + ) => Promise; +}; + +export type x402Erc7710ClientConfig = { + delegationProvider: x402DelegationProvider; + fallbackClient?: x402SchemeNetworkClientLike; +}; + +/** + * Normalize and validate a delegation payload before publishing it. + * + * @param payload - Delegation payload returned by the configured provider. + * @returns The normalized payload with checksum addresses. + */ +function normalizeDelegationPayload( + payload: x402DelegationPaymentPayload, +): x402DelegationPaymentPayload { + if (!isHex(payload.permissionContext) || payload.permissionContext === '0x') { + throw new Error( + 'Invalid delegation payload: permissionContext must be non-empty hex data', + ); + } + + return { + delegationManager: getAddress(payload.delegationManager), + permissionContext: payload.permissionContext, + delegator: getAddress(payload.delegator), + }; +} + +/** + * x402 `SchemeNetworkClient`-compatible implementation for ERC-7710 payments. + * + * This class uses structural typing and intentionally does not import x402 types, + * so it can be consumed without adding a direct dependency on x402 packages. + */ +export class x402Erc7710Client { + readonly scheme = 'exact'; + + readonly #delegationProvider: x402DelegationProvider; + + readonly #fallbackClient?: x402SchemeNetworkClientLike; + + constructor(config: x402Erc7710ClientConfig) { + this.#delegationProvider = config.delegationProvider; + this.#fallbackClient = config.fallbackClient; + } + + async createPaymentPayload( + x402Version: number, + paymentRequirements: x402PaymentRequirements, + context?: Record, + ): Promise { + const assetTransferMethod = paymentRequirements.extra?.assetTransferMethod; + + if (assetTransferMethod !== 'erc7710') { + if (this.#fallbackClient) { + return this.#fallbackClient.createPaymentPayload( + x402Version, + paymentRequirements, + context, + ); + } + + const invalidAssetTransferMethod = + typeof assetTransferMethod === 'string' + ? `"${assetTransferMethod}"` + : JSON.stringify(assetTransferMethod); + + throw new Error( + `x402Erc7710Client can only process assetTransferMethod "erc7710". Received: ${invalidAssetTransferMethod}`, + ); + } + + const delegation = await this.#delegationProvider(paymentRequirements); + + return { + x402Version, + payload: normalizeDelegationPayload(delegation), + }; + } +} diff --git a/packages/x402/src/x402ExactEvmErc7710ServerScheme.ts b/packages/x402/src/x402ExactEvmErc7710ServerScheme.ts new file mode 100644 index 00000000..380ccb76 --- /dev/null +++ b/packages/x402/src/x402ExactEvmErc7710ServerScheme.ts @@ -0,0 +1,40 @@ +import type { Network, PaymentRequirements } from '@x402/core/types'; +import { ExactEvmScheme } from '@x402/evm/exact/server'; + +import { x402Erc7710Server } from './x402Server'; + +/** + * Exact EVM server scheme that injects ERC-7710 payment requirement fields. + */ +export class x402ExactEvmErc7710ServerScheme extends ExactEvmScheme { + readonly #erc7710Server = new x402Erc7710Server(); + + async enhancePaymentRequirements( + paymentRequirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + facilitatorExtensions: string[], + ): Promise { + const baseRequirements = await super.enhancePaymentRequirements( + paymentRequirements, + supportedKind, + facilitatorExtensions, + ); + + if (baseRequirements.extra?.assetTransferMethod !== 'erc7710') { + return baseRequirements; + } + + const enhancedRequirements = + await this.#erc7710Server.enhancePaymentRequirements( + baseRequirements, + supportedKind, + ); + + return enhancedRequirements as PaymentRequirements; + } +} diff --git a/packages/x402/src/x402Server.ts b/packages/x402/src/x402Server.ts new file mode 100644 index 00000000..784958a7 --- /dev/null +++ b/packages/x402/src/x402Server.ts @@ -0,0 +1,109 @@ +import { type Address, getAddress } from 'viem'; + +import type { x402PaymentRequirements } from './x402Client'; + +export type x402Erc7710ServerConfig = { + allowAssetTransferMethodOverride?: boolean; +}; + +/** + * Validate and normalize optional facilitator address metadata. + * + * @param publishedAddresses - Optional facilitator address list from `supportedKind.extra`. + * @returns A normalized checksum address list, or `undefined` when no list is provided. + */ +function validateFacilitatorAddresses( + publishedAddresses: unknown, +): Address[] | undefined { + if (publishedAddresses === undefined) { + return undefined; + } + + if (!Array.isArray(publishedAddresses)) { + throw new Error( + 'Invalid facilitatorAddresses specified: expected an array of addresses', + ); + } + + if (publishedAddresses.length === 0) { + throw new Error( + 'Invalid facilitatorAddresses specified: expected at least one address', + ); + } + + const normalizedAddresses: Address[] = []; + const validationErrors: string[] = []; + + publishedAddresses.forEach((address, index) => { + if (typeof address !== 'string') { + validationErrors.push(`facilitatorAddresses[${index}] must be a string`); + return; + } + + try { + normalizedAddresses.push(getAddress(address)); + } catch { + validationErrors.push( + `facilitatorAddresses[${index}] is not a valid address: "${address}"`, + ); + } + }); + + if (validationErrors.length > 0) { + throw new Error( + `Invalid facilitatorAddresses specified: ${validationErrors.join('; ')}`, + ); + } + + return normalizedAddresses; +} + +/** + * x402 `SchemeNetworkServer`-compatible implementation for publishing + * `assetTransferMethod: "erc7710"` in payment requirements. + * + * This class uses structural typing and intentionally does not import x402 types, + * so it can be consumed without adding a direct dependency on x402 packages. + */ +export class x402Erc7710Server { + readonly scheme = 'exact'; + + readonly #allowAssetTransferMethodOverride: boolean; + + constructor(config?: x402Erc7710ServerConfig) { + this.#allowAssetTransferMethodOverride = + config?.allowAssetTransferMethodOverride ?? false; + } + + async enhancePaymentRequirements( + paymentRequirements: x402PaymentRequirements, + supportedKind: { + extra?: Record; + }, + ): Promise { + const existingMethod = paymentRequirements.extra?.assetTransferMethod; + + if ( + typeof existingMethod === 'string' && + existingMethod !== 'erc7710' && + !this.#allowAssetTransferMethodOverride + ) { + throw new Error( + `Cannot overwrite existing assetTransferMethod "${existingMethod}" with "erc7710"`, + ); + } + + const facilitatorAddresses = validateFacilitatorAddresses( + supportedKind.extra?.facilitatorAddresses, + ); + + return { + ...paymentRequirements, + extra: { + ...(paymentRequirements.extra ?? {}), + ...(facilitatorAddresses ? { facilitatorAddresses } : {}), + assetTransferMethod: 'erc7710', + }, + }; + } +} diff --git a/packages/x402/test/x402Client.test.ts b/packages/x402/test/x402Client.test.ts new file mode 100644 index 00000000..f9e50b0c --- /dev/null +++ b/packages/x402/test/x402Client.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { x402PaymentRequirements } from '../src/x402Client'; +import { x402Erc7710Client } from '../src/x402Client'; + +const baseRequirements: x402PaymentRequirements = { + scheme: 'exact', + network: 'eip155:8453', + asset: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + amount: '1000', + payTo: '0x1111111111111111111111111111111111111111', + maxTimeoutSeconds: 300, + extra: { + assetTransferMethod: 'erc7710', + }, +}; + +describe('x402Erc7710Client', () => { + it('exposes the exact scheme identifier', () => { + const client = new x402Erc7710Client({ + delegationProvider: vi.fn(), + }); + + expect(client.scheme).toBe('exact'); + }); + + it('creates an ERC-7710 payload and normalizes addresses', async () => { + const delegationProvider = vi.fn().mockResolvedValue({ + delegationManager: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + permissionContext: '0x1234', + delegator: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }); + const client = new x402Erc7710Client({ delegationProvider }); + + const payload = await client.createPaymentPayload(2, baseRequirements); + + expect(delegationProvider).toHaveBeenCalledWith(baseRequirements); + expect(payload).toEqual({ + x402Version: 2, + payload: { + delegationManager: '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa', + permissionContext: '0x1234', + delegator: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + }); + }); + + it('throws when permissionContext is empty hex', async () => { + const client = new x402Erc7710Client({ + delegationProvider: vi.fn().mockResolvedValue({ + delegationManager: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + permissionContext: '0x', + delegator: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }), + }); + + await expect( + client.createPaymentPayload(2, baseRequirements), + ).rejects.toThrow( + 'Invalid delegation payload: permissionContext must be non-empty hex data', + ); + }); + + it('throws when permissionContext is not hex', async () => { + const client = new x402Erc7710Client({ + delegationProvider: vi.fn().mockResolvedValue({ + delegationManager: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + permissionContext: 'not-hex', + delegator: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }), + }); + + await expect( + client.createPaymentPayload(2, baseRequirements), + ).rejects.toThrow( + 'Invalid delegation payload: permissionContext must be non-empty hex data', + ); + }); + + it('delegates to fallback client for non-erc7710 methods', async () => { + const fallbackResult = { + x402Version: 2, + payload: { kind: 'fallback' }, + }; + const fallbackClient = { + scheme: 'exact', + createPaymentPayload: vi.fn().mockResolvedValue(fallbackResult), + }; + const client = new x402Erc7710Client({ + delegationProvider: vi.fn(), + fallbackClient, + }); + const requirements: x402PaymentRequirements = { + ...baseRequirements, + extra: { assetTransferMethod: 'eip3009' }, + }; + const context = { marker: 'ctx' }; + + const result = await client.createPaymentPayload(2, requirements, context); + + expect(fallbackClient.createPaymentPayload).toHaveBeenCalledWith( + 2, + requirements, + context, + ); + expect(result).toEqual(fallbackResult); + }); + + it('throws for non-erc7710 methods without fallback', async () => { + const client = new x402Erc7710Client({ + delegationProvider: vi.fn(), + }); + const requirements: x402PaymentRequirements = { + ...baseRequirements, + extra: { assetTransferMethod: 'eip3009' }, + }; + + await expect(client.createPaymentPayload(2, requirements)).rejects.toThrow( + 'x402Erc7710Client can only process assetTransferMethod "erc7710". Received: "eip3009"', + ); + }); + + it('throws with undefined method when extra is missing', async () => { + const client = new x402Erc7710Client({ + delegationProvider: vi.fn(), + }); + const requirements: x402PaymentRequirements = { + ...baseRequirements, + extra: undefined, + }; + + await expect(client.createPaymentPayload(2, requirements)).rejects.toThrow( + 'x402Erc7710Client can only process assetTransferMethod "erc7710". Received: undefined', + ); + }); +}); diff --git a/packages/x402/test/x402ExactEvmErc7710ServerScheme.test.ts b/packages/x402/test/x402ExactEvmErc7710ServerScheme.test.ts new file mode 100644 index 00000000..2a4721fb --- /dev/null +++ b/packages/x402/test/x402ExactEvmErc7710ServerScheme.test.ts @@ -0,0 +1,96 @@ +import type { PaymentRequirements } from '@x402/core/types'; +import { ExactEvmScheme } from '@x402/evm/exact/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { x402ExactEvmErc7710ServerScheme } from '../src/x402ExactEvmErc7710ServerScheme'; +import { x402Erc7710Server } from '../src/x402Server'; + +describe('x402ExactEvmErc7710ServerScheme', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns base requirements unchanged for non-erc7710 methods', async () => { + const baseRequirements = { + scheme: 'exact', + network: 'eip155:8453', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + amount: '1000', + payTo: '0x1111111111111111111111111111111111111111', + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: 'eip3009' }, + } as unknown as PaymentRequirements; + const superSpy = vi + .spyOn(ExactEvmScheme.prototype, 'enhancePaymentRequirements') + .mockResolvedValue(baseRequirements); + const erc7710Spy = vi.spyOn( + x402Erc7710Server.prototype, + 'enhancePaymentRequirements', + ); + const scheme = new x402ExactEvmErc7710ServerScheme(); + const supportedKind = { + x402Version: 2, + scheme: 'exact', + network: 'eip155:8453' as const, + }; + const facilitatorExtensions = ['extension']; + + const result = await scheme.enhancePaymentRequirements( + baseRequirements, + supportedKind, + facilitatorExtensions, + ); + + expect(superSpy).toHaveBeenCalledWith( + baseRequirements, + supportedKind, + facilitatorExtensions, + ); + expect(erc7710Spy).not.toHaveBeenCalled(); + expect(result).toBe(baseRequirements); + }); + + it('enhances requirements for erc7710 methods', async () => { + const baseRequirements = { + scheme: 'exact', + network: 'eip155:8453', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + amount: '1000', + payTo: '0x1111111111111111111111111111111111111111', + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: 'erc7710' }, + } as unknown as PaymentRequirements; + const enhancedRequirements = { + ...baseRequirements, + extra: { + ...baseRequirements.extra, + facilitatorAddresses: ['0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa'], + }, + } as unknown as PaymentRequirements; + vi.spyOn( + ExactEvmScheme.prototype, + 'enhancePaymentRequirements', + ).mockResolvedValue(baseRequirements); + const erc7710Spy = vi + .spyOn(x402Erc7710Server.prototype, 'enhancePaymentRequirements') + .mockResolvedValue(enhancedRequirements); + const scheme = new x402ExactEvmErc7710ServerScheme(); + const supportedKind = { + x402Version: 2, + scheme: 'exact', + network: 'eip155:8453' as const, + extra: { + facilitatorAddresses: ['0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + }, + }; + + const result = await scheme.enhancePaymentRequirements( + baseRequirements, + supportedKind, + [], + ); + + expect(erc7710Spy).toHaveBeenCalledWith(baseRequirements, supportedKind); + expect(result).toEqual(enhancedRequirements); + }); +}); diff --git a/packages/x402/test/x402Server.test.ts b/packages/x402/test/x402Server.test.ts new file mode 100644 index 00000000..1946817d --- /dev/null +++ b/packages/x402/test/x402Server.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import type { x402PaymentRequirements } from '../src/x402Client'; +import { x402Erc7710Server } from '../src/x402Server'; + +const baseRequirements: x402PaymentRequirements = { + scheme: 'exact', + network: 'eip155:8453', + asset: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + amount: '1000', + payTo: '0x1111111111111111111111111111111111111111', + maxTimeoutSeconds: 300, + extra: {}, +}; + +describe('x402Erc7710Server', () => { + it('exposes the exact scheme identifier', () => { + const server = new x402Erc7710Server(); + + expect(server.scheme).toBe('exact'); + }); + + it('sets assetTransferMethod to erc7710', async () => { + const server = new x402Erc7710Server(); + + const result = await server.enhancePaymentRequirements( + baseRequirements, + {}, + ); + + expect(result.extra?.assetTransferMethod).toBe('erc7710'); + }); + + it('handles payment requirements with no extra', async () => { + const server = new x402Erc7710Server(); + const requirementsWithoutExtra: x402PaymentRequirements = { + ...baseRequirements, + extra: undefined, + }; + + const result = await server.enhancePaymentRequirements( + requirementsWithoutExtra, + {}, + ); + + expect(result.extra).toEqual({ + assetTransferMethod: 'erc7710', + }); + }); + + it('preserves existing extra fields', async () => { + const server = new x402Erc7710Server(); + const requirements: x402PaymentRequirements = { + ...baseRequirements, + extra: { existing: 'value' }, + }; + + const result = await server.enhancePaymentRequirements(requirements, {}); + + expect(result.extra).toMatchObject({ + existing: 'value', + assetTransferMethod: 'erc7710', + }); + }); + + it('throws when overwriting a different method is not allowed', async () => { + const server = new x402Erc7710Server(); + const requirements: x402PaymentRequirements = { + ...baseRequirements, + extra: { assetTransferMethod: 'eip3009' }, + }; + + await expect( + server.enhancePaymentRequirements(requirements, {}), + ).rejects.toThrow( + 'Cannot overwrite existing assetTransferMethod "eip3009" with "erc7710"', + ); + }); + + it('allows overwriting when configured', async () => { + const server = new x402Erc7710Server({ + allowAssetTransferMethodOverride: true, + }); + const requirements: x402PaymentRequirements = { + ...baseRequirements, + extra: { assetTransferMethod: 'eip3009' }, + }; + + const result = await server.enhancePaymentRequirements(requirements, {}); + + expect(result.extra?.assetTransferMethod).toBe('erc7710'); + }); + + it('does not set facilitatorAddresses when field is missing', async () => { + const server = new x402Erc7710Server(); + + const result = await server.enhancePaymentRequirements(baseRequirements, { + extra: {}, + }); + + expect(result.extra?.facilitatorAddresses).toBeUndefined(); + }); + + it('normalizes and sets facilitatorAddresses when valid', async () => { + const server = new x402Erc7710Server(); + + const result = await server.enhancePaymentRequirements(baseRequirements, { + extra: { + facilitatorAddresses: [ + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ], + }, + }); + + expect(result.extra?.facilitatorAddresses).toEqual([ + '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa', + '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + ]); + }); + + it('throws when facilitatorAddresses is not an array', async () => { + const server = new x402Erc7710Server(); + + await expect( + server.enhancePaymentRequirements(baseRequirements, { + extra: { facilitatorAddresses: 'not-array' }, + }), + ).rejects.toThrow( + 'Invalid facilitatorAddresses specified: expected an array of addresses', + ); + }); + + it('throws when facilitatorAddresses is empty', async () => { + const server = new x402Erc7710Server(); + + await expect( + server.enhancePaymentRequirements(baseRequirements, { + extra: { facilitatorAddresses: [] }, + }), + ).rejects.toThrow( + 'Invalid facilitatorAddresses specified: expected at least one address', + ); + }); + + it('throws detailed errors for invalid facilitatorAddresses values', async () => { + const server = new x402Erc7710Server(); + + await expect( + server.enhancePaymentRequirements(baseRequirements, { + extra: { + facilitatorAddresses: [123, 'not-an-address'], + }, + }), + ).rejects.toThrow( + 'Invalid facilitatorAddresses specified: facilitatorAddresses[0] must be a string; facilitatorAddresses[1] is not a valid address: "not-an-address"', + ); + }); +}); diff --git a/packages/x402/tsconfig.json b/packages/x402/tsconfig.json new file mode 100644 index 00000000..946266aa --- /dev/null +++ b/packages/x402/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../shared/config/base.tsconfig.json", + "exclude": ["./node_modules/**/*", "./dist/**/*"], + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + } +} diff --git a/packages/x402/tsup.config.ts b/packages/x402/tsup.config.ts new file mode 100644 index 00000000..d1d31001 --- /dev/null +++ b/packages/x402/tsup.config.ts @@ -0,0 +1,12 @@ +import type { Options } from 'tsup'; +import config from '../../shared/config/base.tsup.config'; + +const options: Options = { + ...config, + entry: ['src/index.ts'], + dts: { + entry: ['src/index.ts'], + }, +}; + +export default options; diff --git a/yarn.lock b/yarn.lock index 0cb7bfef..fe1b270b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -857,6 +857,26 @@ __metadata: languageName: node linkType: hard +"@metamask/x402@workspace:packages/x402": + version: 0.0.0-use.local + resolution: "@metamask/x402@workspace:packages/x402" + dependencies: + "@metamask/auto-changelog": "npm:^5.0.2" + "@x402/core": "npm:^2.12.0" + "@x402/evm": "npm:^2.12.0" + eslint: "npm:^9.39.2" + prettier: "npm:^3.5.3" + tsup: "npm:^8.5.0" + typescript: "npm:5.5.4" + viem: "npm:2.31.4" + vitest: "npm:^3.2.4" + peerDependencies: + "@x402/core": ^2.12.0 + "@x402/evm": ^2.12.0 + viem: ^2.31.4 + languageName: unknown + linkType: soft + "@mswjs/interceptors@npm:^0.41.0": version: 0.41.3 resolution: "@mswjs/interceptors@npm:0.41.3" @@ -898,6 +918,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.9.1": + version: 1.9.1 + resolution: "@noble/curves@npm:1.9.1" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/5c82ec828ca4a4218b1666ba0ddffde17afd224d0bd5e07b64c2a0c83a3362483387f55c11cfd8db0fc046605394fe4e2c67fe024628a713e864acb541a7d2bb + languageName: node + linkType: hard + "@noble/curves@npm:1.9.2": version: 1.9.2 resolution: "@noble/curves@npm:1.9.2" @@ -2007,6 +2036,26 @@ __metadata: languageName: node linkType: hard +"@x402/core@npm:^2.12.0, @x402/core@npm:~2.12.0": + version: 2.12.0 + resolution: "@x402/core@npm:2.12.0" + dependencies: + zod: "npm:^3.24.2" + checksum: 10/d9ba28deb5462bac2f60d2764b3973ac0bd3e905100b06c293cedc900802c9e1557d7ef59b0515fa82d3534e1c20099737386906432f7e8768abe6e7200398ee + languageName: node + linkType: hard + +"@x402/evm@npm:^2.12.0": + version: 2.12.0 + resolution: "@x402/evm@npm:2.12.0" + dependencies: + "@x402/core": "npm:~2.12.0" + viem: "npm:^2.48.11" + zod: "npm:^3.24.2" + checksum: 10/8832647ac6f634a07093dd3df99338bffbd9694c8ae6e605e442ad9737155ca136d791e5259f5a7abf26ddc105c325f009b6862d991d9af60dab750d7f4d38c3 + languageName: node + linkType: hard + "@yarnpkg/types@npm:^4.0.1": version: 4.0.1 resolution: "@yarnpkg/types@npm:4.0.1" @@ -2038,7 +2087,7 @@ __metadata: languageName: node linkType: hard -"abitype@npm:^1.0.2, abitype@npm:^1.0.8": +"abitype@npm:1.2.3, abitype@npm:^1.0.2, abitype@npm:^1.0.8": version: 1.2.3 resolution: "abitype@npm:1.2.3" peerDependencies: @@ -2053,6 +2102,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:^1.2.3": + version: 1.2.4 + resolution: "abitype@npm:1.2.4" + peerDependencies: + typescript: ">=5.0.4" + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + checksum: 10/500b317a53b34cb6ffe3e4f090e135972b43cd2fbdfebe64fc497dfd8515d9117919e5f88f0aaede332d29a21c1826be64a3ffa620b0b91c16e8b560b6635714 + languageName: node + linkType: hard + "accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -4957,6 +5021,27 @@ __metadata: languageName: node linkType: hard +"ox@npm:0.14.20": + version: 0.14.20 + resolution: "ox@npm:0.14.20" + dependencies: + "@adraffy/ens-normalize": "npm:^1.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:^1.8.0" + "@scure/bip32": "npm:^1.7.0" + "@scure/bip39": "npm:^1.6.0" + abitype: "npm:^1.2.3" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/96526073193f3a6dd2ccd21bcc255e82c7226d6de63fa17a2021c75232fdc9bc969e75e2cbc0c8d5163d88c575e08dc4c75dec7333b1727f080585f07fc6c1ed + languageName: node + linkType: hard + "ox@npm:0.8.1": version: 0.8.1 resolution: "ox@npm:0.8.1" @@ -6582,6 +6667,27 @@ __metadata: languageName: node linkType: hard +"viem@npm:^2.48.11": + version: 2.49.2 + resolution: "viem@npm:2.49.2" + dependencies: + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:1.8.0" + "@scure/bip32": "npm:1.7.0" + "@scure/bip39": "npm:1.6.0" + abitype: "npm:1.2.3" + isows: "npm:1.0.7" + ox: "npm:0.14.20" + ws: "npm:8.18.3" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/c3a89196c422932c1c0d0071e7ff231fcaa8518d8ae2c8462b15384efa9ad8632797b09e0a164ec83aac909532647086a505fb01a78ace2a001edc5b35ff4ce9 + languageName: node + linkType: hard + "vite-node@npm:3.2.4": version: 3.2.4 resolution: "vite-node@npm:3.2.4" @@ -6832,6 +6938,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.18.3": + version: 8.18.3 + resolution: "ws@npm:8.18.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -6890,3 +7011,10 @@ __metadata: checksum: 10/f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard + +"zod@npm:^3.24.2": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995 + languageName: node + linkType: hard