Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ emvco
endlocal
envoyproxy
esac
fastmcp
felixge
Fiuu
fontawesome
Expand Down Expand Up @@ -83,6 +84,7 @@ jetbrains
Jetpack
jvmargs
Kaia
keccak
keepattributes
keepclassmembers
Klarna
Expand All @@ -93,6 +95,7 @@ ktor
Ktor
KXMYBJWNQ
Lazada
levelname
libpeerconnection
Lightspark
linenums
Expand Down Expand Up @@ -149,6 +152,8 @@ ropeproject
RPCURL
Rulebook
screenreaders
sdjwt
sepolia
setlocal
sharedpref
Shopcider
Expand All @@ -169,6 +174,7 @@ Truelayer
Trulioo
udpa
unmarshal
usdc
viewmodel
vulnz
Wallex
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/linter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
Comment thread
chopmob-cloud marked this conversation as resolved.


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
Loading