Skip to content

fix: surface extra.x402 UCP pay path (base-URI fallback + deepLinkCode) - #6

Open
Robin Schulte (relativvv) wants to merge 10 commits into
mainfrom
fix/ucp-x402-pay-path
Open

fix: surface extra.x402 UCP pay path (base-URI fallback + deepLinkCode)#6
Robin Schulte (relativvv) wants to merge 10 commits into
mainfrom
fix/ucp-x402-pay-path

Conversation

@relativvv

@relativvv Robin Schulte (relativvv) commented Jul 22, 2026

Copy link
Copy Markdown

Note: the mainnet EIP-712 domain-name fix (§1c) was split into #7 and is no longer part of this PR.

Draft — extracted from the agent-shop integration project for upstream review.

This PR bundles the UCP pay-path correctness fixes and agent ergonomics improvements that surfaced while running the plugin end-to-end against a live shop (Base Sepolia settlement working; Base mainnet fix verified by unit test). Seven focused commits, grouped below.

Problem

  1. extra.x402 was never attached to checkout.complete responses. A UCP agent that completed a checkout had no discoverable way to pay the placed order — the augmenter silently produced nothing.
  2. Mainnet invalid_payload. On Base mainnet, every x402 payment was rejected by the CDP facilitator with SWAG_X402__VERIFICATION_FAILED / invalid_payload; no funds moved. The identical code settled fine on Base Sepolia.
  3. Opaque verification failures. When the facilitator rejects /verify, it returns only invalidReason (invalid_payload) — a dead end for operator and agent alike.
  4. deepLinkCode was undiscoverable. The pay route 403s (OWNERSHIP_PROOF_MISSING) until the deep-link code is supplied as ?deepLinkCode=; the agent had to know to concatenate pay_url and deep_link_code.

Root cause

  1. Empty baseUri. X402CheckoutResponseAugmenter::augment() built the pay URL from $context->runtimeConfiguration?->baseUri. With the SDK's StaticRuntimeConfigurationResolver that value is fixed at container-compile time and is empty when no base_uri is configured, so the guard if (!$config->isComplete() || $baseUri === '') always took the empty branch and returned the checkout unchanged. (RequestContext::$host is host-only, no port, so it is not a drop-in substitute for local/dev URLs.)
  2. EIP-712 domain name == display symbol. The 402 challenge advertises the ERC-3009 EIP-712 domain in extra.name / extra.version — the fields the buyer signs over and the facilitator uses to recover the signature. X402RequirementBuilder set extra.name to assetSymbol (default "USDC"). But the EIP-712 domain name is not the symbol: Base mainnet USDC (0x8335…2913) has on-chain name() = "USD Coin" (version "2"), while Base Sepolia test USDC (0x036C…CF7e) really is named "USDC". So the shop advertised "USDC" on mainnet, the facilitator rebuilt the domain with the wrong name, signature recovery failed → invalid_payload. Testnet passed only because the test token's real name happens to be "USDC". (Both name()s verified on-chain.)

What changed

Surface the pay path (base-URI resolver):

  • src/Ucp/UcpBaseUriResolver.php (new) — a pure helper that resolves the base URL from the configured baseUri if present, else from the HTTP Host header (which carries host+port), choosing http for local hosts and https otherwise.
  • src/Ucp/X402CheckoutResponseAugmenter.php — inject and use the resolver instead of reading runtimeConfiguration->baseUri directly.
  • src/Resources/config/services_ucp.xml — register the resolver, inject it as the augmenter's third argument.
  • tests/Unit/Ucp/UcpBaseUriResolverTest.php (new).

Correct the mainnet EIP-712 domain name:

  • src/Core/X402/Config/X402Config.php + X402ConfigService.php + src/Resources/config/config.xml — new optional config assetEip712Name; assetSymbol's label clarified to "display only".
  • src/Core/X402/X402RequirementBuilder.phpextra.name is now assetEip712Name !== '' ? assetEip712Name : assetSymbol (empty falls back to the symbol, so testnet is unchanged).
  • tests/Unit/Core/X402/X402RequirementBuilderTest.php + tests/Unit/Support/X402Fixtures.php — fallback + configured-name cases.
  • docs/spec.md — documents the EIP-712 domain-name requirement.

Actionable verification errors:

  • src/Core/X402/X402FacilitatorClient.php — success log enriched with network, asset, domainName, domainVersion.
  • src/Core/X402/X402SettlementService.php — inject Psr\Log\LoggerInterface (registered in services.xml); log a warning with the advertised domain + network + asset + raw facilitator body before the verify-failure throw.
  • src/Core/X402/Exception/X402Exception.phpverificationFailed() takes an optional array $domain and surfaces it under meta.parameters.domain (name/version/network/asset) so an agent can self-diagnose a domain mismatch. Message template unchanged.
  • tests/Unit/Core/X402/X402SettlementServiceTest.php — asserts the domain in the error params.

Pre-assemble deepLinkCode into pay_url:

  • src/Ucp/X402CheckoutResponseAugmenter.phppay_url now ships with ?deepLinkCode=<rawurlencode(code)> already appended; deep_link_code stays as a sibling for back-compat. (X402PayRequest::extractDeepLinkCode reads the query param first, so the pay route needs no change.) Null-safe under strict_types (getDeepLinkCode() ?? '').
  • tests/bootstrap.php (new) + phpunit.xml.dist — the plugin does not declare ucp-php-sdk (it is provided by the platform lane at runtime); the bootstrap loads the plugin autoloader then registers the SDK's PSR-4 prefixes (guarded) so unit tests can construct Ucp\Sdk\* types without pulling in the lane's higher PHP floor.
  • tests/Unit/Ucp/X402CheckoutResponseAugmenterTest.php (new).

Testing

  • php -l clean on all changed PHP files.
  • Full suite: vendor/bin/phpunit95 tests, 197 assertions; 93 passing, 2 errors. Both errors are X402CheckoutResponseAugmenterTest failing with Interface "Ucp\Sdk\Contract\CheckoutResponseAugmenterInterface" not found — see the SDK note below; every other test (including the base-URI resolver, requirement builder, and settlement-service tests central to this PR) passes.
  • Verified live end-to-end against a running shop on Base Sepolia: UCP discovery → complete_in_progress + x402 → on-chain USDC settlement → order paid. The mainnet EIP-712 fix was verified by unit test; a live mainnet purchase is the follow-up. Config-only unblock for an existing mainnet shop: system:config:set SwagX402Payments.config.assetEip712Name "USD Coin".

Notes for reviewers

  • ⚠️ SDK availability in the test env (needs a reviewer decision). The two new UCP augmenter tests need ucp-php-sdk to be resolvable. The bundled tests/bootstrap.php locates it at a monorepo-relative path (../../../../vendor/ucp-php-sdk) that reflects the agent-shop platform-lane layout; in this standalone repo that path does not exist, so those two tests error with Interface "Ucp\Sdk\Contract\CheckoutResponseAugmenterInterface" not found. This is a pre-existing environment gap (upstream main ships src/Ucp/* using Ucp\Sdk\* but has no UCP unit tests, and phpunit.xml.dist bootstraps vendor/autoload.php which never provides the SDK). To run these two tests upstream, please decide how CI should provide the SDK (e.g. require-dev + a repositories entry, or a CI checkout step) and generalize the bootstrap's SDK discovery. This is exactly the "align the bootstrap with the sibling plugin's mutually-exclusive lane detection" follow-up flagged in the source change notes.
  • Shared config files with the CDP PR. This PR touches X402Config.php, X402ConfigService.php, config.xml, and services.xml, which are also touched by the companion draft "feat: CDP mainnet facilitator auth (per-request signed EdDSA JWT)". That CDP PR should land first; this branch was replayed off main without the CDP fields (only the assetEip712Name correctness field was kept in the config classes), so a straightforward rebase after the CDP PR merges will reconcile the two.
  • The extra.x402 object is serialized as a top-level x402 key in the response (because Ucp\Sdk\Model\Checkout\Checkout::toArray() does array_merge($data, $extra)), not nested under extra. Not changed here; documented so integrators look in the right place.

…402 is surfaced

The augmenter read runtimeConfiguration.baseUri, which is compile-time-empty on
this stack (StaticRuntimeConfigurationResolver), so extra.x402 (pay_url +
deepLinkCode) was never attached to checkout.complete responses. Resolve the
base URL from the Host header (carries host+port) via a pure UcpBaseUriResolver.
… param name

The UCP checkout completion response exposed pay_url and deep_link_code as
separate extra.x402 fields, requiring an agent to already know to append
?deepLinkCode=<code> itself. pay_url now ships with the code pre-assembled
(deep_link_code stays as a sibling for back-compat); X402PayRequest already
reads deepLinkCode from the query string first, so the pay route needs no
change.
…ink null

tests/bootstrap.php no longer requires the lane's vendor/autoload.php,
which runs the lane's platform_check.php and aborts below PHP 8.4.1 -
regressing the plugin's own PHP 8.2 floor and loading two autoloaders
at once. Instead it captures the plugin's own ClassLoader and registers
the vendored ucp-php-sdk's declared PSR-4 prefixes (Ucp\Sdk\ and
Ucp\Sdk\Symfony\) directly, guarded by is_dir/is_file, mirroring the
sibling SwagAgenticCommerce bootstrap's guard style.

Also guards rawurlencode(getDeepLinkCode()) with ?? '' since the
getter is nullable under strict_types.
… gate

- bootstrap: probe multiple SDK locations, skip augmenter test when absent
- settlement: keep class within cyclomatic budget via describeDomain helper
- mago/depcheck: mirror src/Ucp ignores for the SDK-referencing Ucp test
The mainnet EIP-712 domain-name decoupling (assetEip712Name) is split out
into its own standalone PR (#7) as the isolated mainnet blocker. This branch
now carries only the base-URI resolver (§1), verify-failure logging + error
meta (§1d), and deepLinkCode-in-pay_url (§1e), plus the test bootstrap /
quality-gate machinery.

Removed from here (now in #7):
- X402RequirementBuilder: extra.name back to $config->assetSymbol
- X402Config / X402ConfigService / config.xml: dropped assetEip712Name field
- X402Fixtures / X402RequirementBuilderTest: dropped the assetEip712Name
  fixture param and the fallback/configured-name cases
- docs/spec.md: dropped the EIP-712 domain-name note

§1d coupling: X402SettlementServiceTest advertises the domain via the
requirements' extra.name. With §1c gone, extra.name is assetSymbol again, so
the test's fixture value and expected domain.name are set to 'USDC' (the
fixture assetSymbol) instead of 'USD Coin'.
@relativvv Robin Schulte (relativvv) changed the title fix: surface extra.x402 pay path + correct mainnet EIP-712 domain name fix: surface extra.x402 pay path (base-URI fallback + deepLinkCode) and verify-failure diagnostics Jul 22, 2026
Defer the verify-failure diagnostics (X402Exception/X402SettlementService/
X402FacilitatorClient + services.xml + its test) and the standalone-CI test
bootstrap (tests/bootstrap.php, phpunit.xml.dist, mago.toml,
composer-dependency-analyser.php) to a separate follow-up PR. The augmenter
unit test rides along with that bootstrap (it needs the SDK on the classpath),
so it is deferred too. Net PR is now UcpBaseUriResolver (+test), the augmenter
change, and its service wiring.
@relativvv Robin Schulte (relativvv) changed the title fix: surface extra.x402 pay path (base-URI fallback + deepLinkCode) and verify-failure diagnostics fix: surface extra.x402 UCP pay path (base-URI fallback + deepLinkCode) Jul 23, 2026
@relativvv

Copy link
Copy Markdown
Author

Slimmed this PR down to its core: the UCP pay-path base-URI fallback (UcpBaseUriResolver + augmenter wiring) and embedding deepLinkCode in pay_url. Net diff is now 4 files / +87.

Deferred to a follow-up PR:

  • Verify-failure diagnostics (X402Exception domain param, X402SettlementService logger + describeDomain(), X402FacilitatorClient success-log, services.xml, and the settlement test) — orphaned here once the mainnet EIP-712 fix moved to its own PR.
  • Standalone-CI test bootstrap (tests/bootstrap.php, phpunit.xml.dist, mago.toml, composer-dependency-analyser.php) plus the augmenter unit test that depends on it (the SDK contract must be on the classpath). Best re-added once CI provisions ucp-php-sdk.

Full suite green (90 tests).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant