diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..cdca33d9 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -47,6 +47,7 @@ emvco endlocal envoyproxy esac +fastmcp felixge Fiuu fontawesome @@ -83,6 +84,7 @@ jetbrains Jetpack jvmargs Kaia +keccak keepattributes keepclassmembers Klarna @@ -93,6 +95,7 @@ ktor Ktor KXMYBJWNQ Lazada +levelname libpeerconnection Lightspark linenums @@ -149,6 +152,8 @@ ropeproject RPCURL Rulebook screenreaders +sdjwt +sepolia setlocal sharedpref Shopcider @@ -169,6 +174,7 @@ Truelayer Trulioo udpa unmarshal +usdc viewmodel vulnz Wallex diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index fea1b9c1..e6d040c0 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -23,7 +23,8 @@ jobs: LOG_LEVEL: WARN SHELLCHECK_OPTS: -e SC1091 -e 2086 VALIDATE_ALL_CODEBASE: false - FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" + FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/|code/web-client/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" + VALIDATE_BIOME_LINT: false VALIDATE_BIOME_FORMAT: false VALIDATE_PYTHON_BLACK: false VALIDATE_PYTHON_FLAKE8: false diff --git a/code/samples/python/src/roles/x402_credentials_provider_mcp/server.py b/code/samples/python/src/roles/x402_credentials_provider_mcp/server.py index c27d68ce..a42b8aad 100644 --- a/code/samples/python/src/roles/x402_credentials_provider_mcp/server.py +++ b/code/samples/python/src/roles/x402_credentials_provider_mcp/server.py @@ -98,6 +98,22 @@ def _load_persisted_mandate(filename: str) -> str | None: return None +def _verified_amount_cents(chain: PaymentMandateChain) -> int: + """Return the signed amount (minor units) from the verified closed mandate. + + The amount is always taken from the REQUIRED, signed payment_amount of the + verified mandate. There is deliberately no hardcoded fallback: authorizing a + fabricated amount would sign an EIP-3009 authorization the user never + mandated. Raises AttributeError if the verified mandate carries no usable + payment amount -- whether the field is absent or present but null -- which the + caller must treat as a verification failure (fail closed). + """ + amount = chain.closed_mandate.payment_amount.amount + if amount is None: + raise AttributeError("verified mandate payment_amount.amount is null") + return amount + + @mcp.tool() def issue_payment_credential( payment_mandate_chain_id: str, @@ -145,19 +161,27 @@ def issue_payment_credential( if violations: return {"error": "verification_failed", "message": "; ".join(violations)} - # Extract 'to' address and value from the verified mandate chain + # Amount is a REQUIRED, signed field of the verified mandate. Authorize + # exactly that amount and fail closed if it is somehow absent. Never fall back + # to a hardcoded amount: doing so would sign an EIP-3009 authorization for a + # value the user never mandated (previously any missing field silently + # defaulted the transfer to 1250 cents). try: - payee_address = chain.closed_mandate.payment_instrument.payee_address - amount_cents = chain.closed_mandate.payment_amount.amount - if not payee_address: - payee_address = ( - os.environ.get("MERCHANT_WALLET_ADDRESS") or DEFAULT_MERCHANT_ADDRESS - ) + amount_cents = _verified_amount_cents(chain) except AttributeError: - payee_address = ( - os.environ.get("MERCHANT_WALLET_ADDRESS") or DEFAULT_MERCHANT_ADDRESS - ) - amount_cents = 1250 + _logger.error("verified mandate is missing a payment amount") + return { + "error": "verification_failed", + "message": "verified mandate is missing a payment amount", + } + + # Destination for the transfer. The verified payment instrument does not yet + # carry a payee address (type-specific instrument fields are dropped before + # signing, see issue #299), so use the credential provider's configured payout + # address. + payee_address = ( + os.environ.get("MERCHANT_WALLET_ADDRESS") or DEFAULT_MERCHANT_ADDRESS + ) # x402 Binding Check: Hash mandate to create exactly 32-byte EIP-3009 Nonce nonce = Web3.keccak(text=mandate_chain) diff --git a/code/samples/python/src/roles/x402_credentials_provider_mcp/server_tests.py b/code/samples/python/src/roles/x402_credentials_provider_mcp/server_tests.py new file mode 100644 index 00000000..63f704af --- /dev/null +++ b/code/samples/python/src/roles/x402_credentials_provider_mcp/server_tests.py @@ -0,0 +1,74 @@ +"""Regression tests for x402 credential provider amount handling (issue #299). + +The credential provider must authorize the amount signed in the verified +payment mandate, never a hardcoded fallback. Before the fix, an AttributeError +on a missing instrument field caused the handler to silently authorize a fixed +1250 cents regardless of what the user actually mandated. +""" + +import sys + +from pathlib import Path + +import pytest + + +# Make the samples 'src' root importable (roles.*, common.*). +_SRC = Path(__file__).resolve().parents[2] +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from roles.x402_credentials_provider_mcp import server # noqa: E402 + + +class _Amount: + + def __init__(self, amount): + self.amount = amount + + +class _Mandate: + + def __init__(self, amount): + self.payment_amount = _Amount(amount) + + +class _Chain: + + def __init__(self, amount): + self.closed_mandate = _Mandate(amount) + + +def test_amount_uses_signed_value(): + assert server._verified_amount_cents(_Chain(4200)) == 4200 + + +def test_amount_honors_any_signed_value(): + # The pre-fix code authorized a fixed 1250 regardless of the mandate. + for value in (1, 999, 1249, 1251, 500000): + assert server._verified_amount_cents(_Chain(value)) == value + + +def test_amount_missing_fails_closed(): + class _NoAmount: + pass + + # A verified mandate without a payment amount must raise, so the caller can + # fail closed rather than fabricate a value. + with pytest.raises(AttributeError): + server._verified_amount_cents(_NoAmount()) + + +def test_amount_null_fails_closed(): + # payment_amount is present but its amount is null. The null slips past + # attribute access, so without an explicit guard the function would return + # None and later crash on None * 10000 instead of failing closed. It must + # raise AttributeError so the caller returns verification_failed. + with pytest.raises(AttributeError): + server._verified_amount_cents(_Chain(None)) + + +def test_handler_does_not_fabricate_amount(): + # Guard against reintroducing the hardcoded amount fallback in the handler. + source = Path(server.__file__).read_text(encoding="utf-8") + assert "amount_cents = 1250" not in source