diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..9c4dc3982b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/.pnpm-store +**/__pycache__ +**/*.pyc +**/.pytest_cache +**/.venv +**/dist +**/.git diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 9bf90d3795..20132d99c2 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -849,6 +849,61 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1) + ../../typescript/packages/mechanisms/cardano: + dependencies: + '@emurgo/cardano-serialization-lib-nodejs': + specifier: '>=11.5.0' + version: 15.0.3 + '@x402/core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) + tsx: + specifier: ^4.19.2 + version: 4.20.4 + typescript: + specifier: ^5.7.3 + version: 5.9.2 + vite: + specifier: ^6.2.6 + version: 6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1)) + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1) + ../../typescript/packages/mechanisms/evm: dependencies: '@x402/core': @@ -2849,6 +2904,9 @@ packages: '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@emurgo/cardano-serialization-lib-nodejs@15.0.3': + resolution: {integrity: sha512-CZkAF7P3Ip3gUCAa6v93DLKp9hGqsfE6F/b3Qrqvym7rEJKi3j+dDk/OPxtmynWWGHhqXL85vzFOOwVduhLAjA==} + '@es-joy/jsdoccomment@0.50.2': resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==} engines: {node: '>=18'} @@ -6450,10 +6508,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -11095,6 +11149,8 @@ snapshots: tslib: 2.8.1 optional: true + '@emurgo/cardano-serialization-lib-nodejs@15.0.3': {} + '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.8 @@ -15385,7 +15441,7 @@ snapshots: '@typescript-eslint/project-service@8.40.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.2) '@typescript-eslint/types': 8.48.0 debug: 4.4.1 typescript: 5.9.2 @@ -17061,8 +17117,6 @@ snapshots: ansi-styles@5.2.0: {} - ansi-styles@6.2.1: {} - ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -22014,7 +22068,7 @@ snapshots: wrap-ansi@8.1.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 5.1.2 strip-ansi: 7.1.0 diff --git a/go/mechanisms/cardano/constants.go b/go/mechanisms/cardano/constants.go new file mode 100644 index 0000000000..34e8a81660 --- /dev/null +++ b/go/mechanisms/cardano/constants.go @@ -0,0 +1,188 @@ +// Package cardano provides x402 Cardano `exact` mechanism constants and types. +// +// The package mirrors the canonical TypeScript and Python implementations under +// typescript/packages/mechanisms/cardano and python/x402/mechanisms/cardano so +// error codes, network identifiers, and asset markers are byte-for-byte +// identical across all SDKs. +// +// The Go SDK is server-side only — it never decodes Cardano CBOR. All chain +// inspection happens in a remote facilitator. Use the +// `mechanisms/cardano/exact/server` subpackage to register the Cardano scheme +// with an x402.X402ResourceServer. +package cardano + +import "time" + +const ( + // SchemeExact is the scheme identifier for the `exact` payment scheme. + SchemeExact = "exact" + + // CAIP-2-ish network identifiers (per spec — NOT canonical CAIP-2). + + // CardanoMainnet is the x402 network identifier for Cardano mainnet. + CardanoMainnet = "cardano:mainnet" + // CardanoPreprod is the x402 network identifier for Cardano preprod testnet. + CardanoPreprod = "cardano:preprod" + // CardanoPreview is the x402 network identifier for Cardano preview testnet. + CardanoPreview = "cardano:preview" + + // Cardano network IDs encoded in transaction bodies. + + // NetworkIDMainnet is the Cardano body network_id for mainnet. + NetworkIDMainnet = 1 + // NetworkIDTestnet is the Cardano body network_id for any testnet. + NetworkIDTestnet = 0 + + // LovelaceAsset is the asset marker for native ADA. The facilitator + // special-cases this value: lovelace lives in an output's `coin` field + // rather than the multi-asset map, so verification compares against + // `output.coin` when the asset string is exactly "lovelace". + LovelaceAsset = "lovelace" + + // USDMDefaultDecimals is the default decimal precision for USDM + // (and most stablecoins on Cardano). + USDMDefaultDecimals = 6 + + // USDMMainnetPolicyID is the USDM token policy ID on mainnet. + USDMMainnetPolicyID = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad" + // USDMPreprodPolicyID is the USDM token policy ID on preprod. + USDMPreprodPolicyID = "16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde" + // USDMAssetNameHex is the CIP-68 (333 prefix + "USDM") asset name hex. + USDMAssetNameHex = "0014df105553444d" + + // USDMMainnetAsset is the full asset unit for USDM on mainnet. + USDMMainnetAsset = USDMMainnetPolicyID + "." + USDMAssetNameHex + // USDMPreprodAsset is the full asset unit for USDM on preprod. + USDMPreprodAsset = USDMPreprodPolicyID + "." + USDMAssetNameHex + + // AssetTransferMethodDefault is the default asset-transfer method + // (sender pays + signs). + AssetTransferMethodDefault = "default" + // AssetTransferMethodMasumi is the Masumi smart-protocol method. + AssetTransferMethodMasumi = "masumi" + // AssetTransferMethodScript is the Plutus-script method. + AssetTransferMethodScript = "script" + + // DefaultMaxTimeoutSeconds is the default `maxTimeoutSeconds` for + // Cardano payment requirements. + DefaultMaxTimeoutSeconds = 300 + + // SettlementTTL is the duplicate-settlement cache lifetime; matches the + // Python facilitator's default. + SettlementTTL = 120 * time.Second + + // Validation regexes. + + // CardanoAssetRegex accepts either the literal "lovelace" (native ADA) or + // `policyId.assetNameHex` for native tokens. + CardanoAssetRegex = `^(lovelace|[0-9a-fA-F]{56}\.[0-9a-fA-F]{0,64})$` + // CardanoAddressRegex accepts both mainnet and testnet bech32 addresses. + CardanoAddressRegex = `^(addr1|addr_test1)[0-9a-z]+$` + // CardanoUTXORefRegex validates a UTXO reference (txHashHex#index). + CardanoUTXORefRegex = `^[0-9a-fA-F]{64}#\d+$` +) + +// Error code constants. These mirror the TypeScript / Python / Java SDKs +// byte-for-byte so cross-language error handling is straightforward. +const ( + // ErrUnsupportedScheme — scheme is not "exact". + ErrUnsupportedScheme = "unsupported_scheme" + // ErrInvalidPayload — generic invalid Cardano payload (decode error, + // missing fields, etc.). + ErrInvalidPayload = "invalid_exact_cardano_payload" + // ErrTransactionDecodeFailed — transaction CBOR could not be decoded. + ErrTransactionDecodeFailed = "invalid_exact_cardano_payload_transaction_decode_failed" + // ErrNetworkIDMismatch — tx body's network_id disagrees with the network. + ErrNetworkIDMismatch = "invalid_exact_cardano_payload_network_id_mismatch" + // ErrNetworkMismatch — accepted.network differs from required network. + ErrNetworkMismatch = "network_mismatch" + // ErrRecipientMismatch — no transaction output pays the required recipient. + ErrRecipientMismatch = "invalid_exact_cardano_payload_recipient_mismatch" + // ErrAssetMismatch — recipient output exists but does not carry the required asset. + ErrAssetMismatch = "invalid_exact_cardano_payload_asset_mismatch" + // ErrAmountInsufficient — recipient + asset matched but amount < requirement. + ErrAmountInsufficient = "invalid_exact_cardano_payload_amount_insufficient" + // ErrNonceInvalid — nonce is not a valid UTXO reference (txHashHex#index). + ErrNonceInvalid = "invalid_exact_cardano_payload_nonce_invalid" + // ErrNonceNotInInputs — nonce UTXO reference is not present among the + // transaction inputs. + ErrNonceNotInInputs = "invalid_exact_cardano_payload_nonce_not_in_inputs" + // ErrNonceNotOnChain — nonce UTXO is unknown or already spent on chain. + ErrNonceNotOnChain = "invalid_exact_cardano_payload_nonce_not_on_chain" + // ErrTTLExpired — transaction's TTL slot is in the past. + ErrTTLExpired = "invalid_exact_cardano_payload_ttl_expired" + // ErrValidityNotYetValid — transaction's validity start slot is still in the future. + ErrValidityNotYetValid = "invalid_exact_cardano_payload_not_yet_valid" + // ErrChainLookupFailed — facilitator failed to query the chain. + ErrChainLookupFailed = "exact_cardano_facilitator_chain_lookup_failed" + // ErrSettlementFailed — submission to chain failed. + ErrSettlementFailed = "exact_cardano_settlement_failed" + // ErrSettlementNotConfirmed — submission accepted but the chain has not yet confirmed. + ErrSettlementNotConfirmed = "exact_cardano_settlement_not_confirmed" + // ErrDuplicateSettlement — repeated settlement attempt with the same transaction. + ErrDuplicateSettlement = "duplicate_settlement" + // ErrScriptAddressMismatch — reconstructed script address disagrees with declared payTo. + ErrScriptAddressMismatch = "invalid_exact_cardano_payload_script_address_mismatch" + // ErrCardanoSDKMissing — optional Cardano SDK is missing. + ErrCardanoSDKMissing = "exact_cardano_sdk_missing" + // ErrTransactionUnsigned — transaction has zero witnesses. + ErrTransactionUnsigned = "invalid_exact_cardano_payload_unsigned" +) + +// NetworkConfig bundles per-network defaults. +type NetworkConfig struct { + // CAIP2 is the x402 network identifier (e.g. "cardano:preprod"). + CAIP2 string + // NetworkID is the Cardano body network_id (1 or 0). + NetworkID int + // DefaultAsset is the asset unit used when a Money-style price is + // supplied without an explicit asset (empty for networks without a + // default — e.g. preview). + DefaultAsset string +} + +// NetworkConfigs maps each x402 Cardano network identifier to its config. +var NetworkConfigs = map[string]NetworkConfig{ + CardanoMainnet: { + CAIP2: CardanoMainnet, + NetworkID: NetworkIDMainnet, + DefaultAsset: USDMMainnetAsset, + }, + CardanoPreprod: { + CAIP2: CardanoPreprod, + NetworkID: NetworkIDTestnet, + DefaultAsset: USDMPreprodAsset, + }, + CardanoPreview: { + CAIP2: CardanoPreview, + NetworkID: NetworkIDTestnet, + DefaultAsset: "", + }, +} + +// IsCardanoNetwork reports whether the supplied identifier names a supported +// Cardano network. +func IsCardanoNetwork(network string) bool { + _, ok := NetworkConfigs[network] + return ok +} + +// GetNetworkConfig returns the network configuration for a Cardano network, +// or an error when the network is not supported. +func GetNetworkConfig(network string) (NetworkConfig, error) { + cfg, ok := NetworkConfigs[network] + if !ok { + return NetworkConfig{}, &UnsupportedNetworkError{Network: network} + } + return cfg, nil +} + +// UnsupportedNetworkError is returned when a network identifier is not a +// recognised Cardano network. +type UnsupportedNetworkError struct { + Network string +} + +func (e *UnsupportedNetworkError) Error() string { + return "unsupported Cardano network: " + e.Network +} diff --git a/go/mechanisms/cardano/constants_test.go b/go/mechanisms/cardano/constants_test.go new file mode 100644 index 0000000000..914351426e --- /dev/null +++ b/go/mechanisms/cardano/constants_test.go @@ -0,0 +1,68 @@ +package cardano + +import ( + "regexp" + "testing" +) + +func TestIsCardanoNetwork(t *testing.T) { + cases := map[string]bool{ + "cardano:mainnet": true, + "cardano:preprod": true, + "cardano:preview": true, + "eip155:8453": false, + "solana:mainnet": false, + "": false, + } + for input, want := range cases { + got := IsCardanoNetwork(input) + if got != want { + t.Errorf("IsCardanoNetwork(%q) = %v, want %v", input, got, want) + } + } +} + +func TestGetNetworkConfig(t *testing.T) { + cfg, err := GetNetworkConfig(CardanoMainnet) + if err != nil || cfg.NetworkID != NetworkIDMainnet { + t.Fatalf("expected mainnet network id 1, got cfg=%+v err=%v", cfg, err) + } + cfg, err = GetNetworkConfig(CardanoPreprod) + if err != nil || cfg.NetworkID != NetworkIDTestnet { + t.Fatalf("expected preprod network id 0, got cfg=%+v err=%v", cfg, err) + } + if _, err := GetNetworkConfig("not-a-network"); err == nil { + t.Fatal("expected error for unknown network") + } +} + +func TestAssetRegex(t *testing.T) { + re := regexp.MustCompile(CardanoAssetRegex) + policy := "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad" + cases := map[string]bool{ + "lovelace": true, + policy + ".0014df105553444d": true, + "LOVELACE": false, // case-sensitive in spec + "0xtoken": false, + policy: false, // no dot + } + for input, want := range cases { + if got := re.MatchString(input); got != want { + t.Errorf("asset regex(%q) = %v, want %v", input, got, want) + } + } +} + +func TestUTXORefRegex(t *testing.T) { + re := regexp.MustCompile(CardanoUTXORefRegex) + tx := "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b" + if !re.MatchString(tx + "#3") { + t.Error("expected utxo ref regex to accept canonical form") + } + if re.MatchString(tx) { + t.Error("utxo ref regex must require '#index'") + } + if re.MatchString("xy#0") { + t.Error("utxo ref regex must require 64-hex tx hash") + } +} diff --git a/go/mechanisms/cardano/exact/server/scheme.go b/go/mechanisms/cardano/exact/server/scheme.go new file mode 100644 index 0000000000..8f93881fb0 --- /dev/null +++ b/go/mechanisms/cardano/exact/server/scheme.go @@ -0,0 +1,204 @@ +// Package server provides the Cardano `exact` server-side scheme. +// +// Resource servers register an instance of ExactCardanoScheme with an +// x402.X402ResourceServer for one or more Cardano networks; the framework +// then takes care of issuing 402 challenges, decoding the +// PAYMENT-SIGNATURE header, dispatching to a remote facilitator's +// /verify and /settle endpoints, and emitting the PAYMENT-RESPONSE header. +// +// The scheme implements x402.SchemeNetworkServer (V2) — no chain-side +// logic lives here; CBOR decoding and rule enforcement happen in a remote +// facilitator (Python reference implementation, or any V2-compatible +// facilitator). +package server + +import ( + "context" + "errors" + "fmt" + "strings" + + x402 "github.com/x402-foundation/x402/go" + "github.com/x402-foundation/x402/go/mechanisms/cardano" + "github.com/x402-foundation/x402/go/types" +) + +// ExactCardanoScheme implements x402.SchemeNetworkServer for the Cardano +// `exact` payment scheme. +// +// Use NewExactCardanoScheme() to construct it; register it with the resource +// server like: +// +// server.Register(cardano.CardanoPreprod, cscheme.NewExactCardanoScheme()) +type ExactCardanoScheme struct { + moneyParsers []x402.MoneyParser +} + +// NewExactCardanoScheme constructs an ExactCardanoScheme with the default +// (USDM-based) Money parser as the final fallback. +func NewExactCardanoScheme() *ExactCardanoScheme { + return &ExactCardanoScheme{} +} + +// Scheme returns the scheme identifier ("exact"). Required by +// x402.SchemeNetworkServer. +func (s *ExactCardanoScheme) Scheme() string { return cardano.SchemeExact } + +// RegisterMoneyParser appends a custom money parser to the chain. Parsers +// are tried in registration order; the first non-nil result wins. The +// default USDM-based conversion is always the final fallback. +// +// Example: +// +// scheme.RegisterMoneyParser(func(amount float64, n x402.Network) (*x402.AssetAmount, error) { +// if amount > 100 { return &x402.AssetAmount{Amount: ..., Asset: "lovelace"}, nil } +// return nil, nil // defer to next parser +// }) +func (s *ExactCardanoScheme) RegisterMoneyParser(parser x402.MoneyParser) *ExactCardanoScheme { + s.moneyParsers = append(s.moneyParsers, parser) + return s +} + +// ParsePrice converts a Price (Money string/number or {amount,asset} map) +// into an AssetAmount. Required by x402.SchemeNetworkServer. +func (s *ExactCardanoScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error) { + netStr := string(network) + if !cardano.IsCardanoNetwork(netStr) { + return x402.AssetAmount{}, &cardano.UnsupportedNetworkError{Network: netStr} + } + + // Pre-parsed AssetAmount style: {amount, asset, [extra]}. + if priceMap, ok := price.(map[string]interface{}); ok { + if amountVal, hasAmount := priceMap["amount"]; hasAmount { + amount, ok := amountVal.(string) + if !ok { + return x402.AssetAmount{}, errors.New("amount must be a string") + } + asset, _ := priceMap["asset"].(string) + if asset == "" { + return x402.AssetAmount{}, fmt.Errorf( + "asset unit must be specified for AssetAmount on network %s", netStr) + } + if err := cardano.ValidateAssetUnit(asset); err != nil { + return x402.AssetAmount{}, err + } + extra, _ := priceMap["extra"].(map[string]interface{}) + if extra == nil { + extra = map[string]interface{}{} + } + return x402.AssetAmount{Amount: amount, Asset: asset, Extra: extra}, nil + } + } + + // Money parsing → custom parsers → default conversion. + decimalStr, err := cardano.ParseMoneyToDecimal(price) + if err != nil { + return x402.AssetAmount{}, err + } + // Try numeric form for custom parsers; ignore parse failures (the + // default path uses the decimal string anyway). + for _, parser := range s.moneyParsers { + decimal, parseErr := strconvFloat(decimalStr) + if parseErr != nil { + continue + } + result, perr := parser(decimal, network) + if perr != nil { + continue + } + if result != nil { + if result.Asset == "" { + return x402.AssetAmount{}, errors.New("money parser returned empty asset") + } + if err := cardano.ValidateAssetUnit(result.Asset); err != nil { + return x402.AssetAmount{}, fmt.Errorf("money parser returned invalid asset: %w", err) + } + return *result, nil + } + } + + return s.defaultMoneyConversion(decimalStr, netStr) +} + +// EnhancePaymentRequirements is the hook the framework calls after +// ParsePrice to merge facilitator-supplied extras into the requirements. +// Required by x402.SchemeNetworkServer. +func (s *ExactCardanoScheme) EnhancePaymentRequirements( + ctx context.Context, + requirements types.PaymentRequirements, + supportedKind types.SupportedKind, + extensionKeys []string, +) (types.PaymentRequirements, error) { + _ = ctx + netStr := string(requirements.Network) + if !cardano.IsCardanoNetwork(netStr) { + return requirements, &cardano.UnsupportedNetworkError{Network: netStr} + } + if requirements.Extra == nil { + requirements.Extra = map[string]interface{}{} + } + // Default assetTransferMethod to "default" if missing. + if _, ok := requirements.Extra["assetTransferMethod"]; !ok { + requirements.Extra["assetTransferMethod"] = cardano.AssetTransferMethodDefault + } + // Merge supported_kind extras (server-side requirements always win). + for k, v := range supportedKind.Extra { + if _, exists := requirements.Extra[k]; !exists { + requirements.Extra[k] = v + } + } + // Carry through requested extension keys. + for _, key := range extensionKeys { + if val, ok := supportedKind.Extra[key]; ok { + requirements.Extra[key] = val + } + } + return requirements, nil +} + +// GetAssetDecimals reports decimal precision for the asset. Cardano native +// ADA always has 6 decimals (lovelace), and most stablecoins use 6 too. +// Override by subclassing or wrapping if you have a custom-decimal token. +// Implements x402.AssetDecimalsProvider. +func (s *ExactCardanoScheme) GetAssetDecimals(asset string, network x402.Network) int { + _ = asset + _ = network + return cardano.USDMDefaultDecimals +} + +// defaultMoneyConversion converts a decimal string (e.g. "1.50") into +// the network's default asset's smallest unit. +func (s *ExactCardanoScheme) defaultMoneyConversion(decimalStr, network string) (x402.AssetAmount, error) { + cfg, err := cardano.GetNetworkConfig(network) + if err != nil { + return x402.AssetAmount{}, err + } + if cfg.DefaultAsset == "" { + return x402.AssetAmount{}, fmt.Errorf( + "no default asset configured for network %s; supply price as {amount, asset}", network) + } + atomic, err := cardano.ConvertToTokenAmount(decimalStr, cardano.USDMDefaultDecimals) + if err != nil { + return x402.AssetAmount{}, err + } + return x402.AssetAmount{ + Amount: atomic, + Asset: cfg.DefaultAsset, + Extra: map[string]interface{}{}, + }, nil +} + +// strconvFloat is a tiny shim so the file is self-contained and to keep +// the imports tight. +func strconvFloat(s string) (float64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, errors.New("empty string") + } + var f float64 + _, err := fmt.Sscanf(s, "%f", &f) + if err != nil { + return 0, err + } + return f, nil +} diff --git a/go/mechanisms/cardano/exact/server/scheme_test.go b/go/mechanisms/cardano/exact/server/scheme_test.go new file mode 100644 index 0000000000..bd3854781b --- /dev/null +++ b/go/mechanisms/cardano/exact/server/scheme_test.go @@ -0,0 +1,128 @@ +package server + +import ( + "context" + "testing" + + x402 "github.com/x402-foundation/x402/go" + "github.com/x402-foundation/x402/go/mechanisms/cardano" + "github.com/x402-foundation/x402/go/types" +) + +func TestSchemeID(t *testing.T) { + s := NewExactCardanoScheme() + if s.Scheme() != "exact" { + t.Errorf("expected scheme %q, got %q", "exact", s.Scheme()) + } +} + +func TestParsePriceWithExplicitAssetAmount(t *testing.T) { + s := NewExactCardanoScheme() + asset, err := s.ParsePrice(map[string]interface{}{ + "amount": "5000000", + "asset": cardano.LovelaceAsset, + "extra": map[string]interface{}{"k": "v"}, + }, x402.Network(cardano.CardanoPreprod)) + if err != nil { + t.Fatal(err) + } + if asset.Amount != "5000000" || asset.Asset != cardano.LovelaceAsset { + t.Errorf("unexpected asset amount: %+v", asset) + } + if asset.Extra["k"] != "v" { + t.Errorf("extra not propagated: %+v", asset.Extra) + } +} + +func TestParsePriceRejectsBadAsset(t *testing.T) { + s := NewExactCardanoScheme() + _, err := s.ParsePrice(map[string]interface{}{ + "amount": "5000000", + "asset": "0xinvalid", + }, x402.Network(cardano.CardanoPreprod)) + if err == nil { + t.Fatal("expected validation error for non-Cardano asset format") + } +} + +func TestParsePriceMoneyDefaultsToUSDM(t *testing.T) { + s := NewExactCardanoScheme() + asset, err := s.ParsePrice("1.50", x402.Network(cardano.CardanoPreprod)) + if err != nil { + t.Fatal(err) + } + // 1.50 with 6 decimals → 1_500_000 of USDM preprod. + if asset.Amount != "1500000" { + t.Errorf("unexpected money conversion: %s", asset.Amount) + } + if asset.Asset != cardano.USDMPreprodAsset { + t.Errorf("expected USDM preprod default; got %s", asset.Asset) + } +} + +func TestParsePriceUnsupportedNetwork(t *testing.T) { + s := NewExactCardanoScheme() + _, err := s.ParsePrice("1", x402.Network("eip155:8453")) + if err == nil { + t.Fatal("expected unsupported network error") + } +} + +func TestEnhancePaymentRequirementsFillsDefaults(t *testing.T) { + s := NewExactCardanoScheme() + r := types.PaymentRequirements{ + Scheme: "exact", + Network: cardano.CardanoPreprod, + Asset: cardano.LovelaceAsset, + Amount: "5000000", + PayTo: "addr_test1qx", + MaxTimeoutSeconds: cardano.DefaultMaxTimeoutSeconds, + } + out, err := s.EnhancePaymentRequirements( + context.Background(), r, types.SupportedKind{}, nil) + if err != nil { + t.Fatal(err) + } + if out.Extra["assetTransferMethod"] != cardano.AssetTransferMethodDefault { + t.Errorf("expected default assetTransferMethod, got %v", out.Extra) + } +} + +func TestEnhancePaymentRequirementsMergesSupportedKindExtras(t *testing.T) { + s := NewExactCardanoScheme() + r := types.PaymentRequirements{ + Scheme: "exact", + Network: cardano.CardanoPreprod, + Asset: cardano.LovelaceAsset, + Amount: "5000000", + PayTo: "addr_test1qx", + Extra: map[string]interface{}{"assetTransferMethod": "default"}, + } + supported := types.SupportedKind{ + Extra: map[string]interface{}{"feePayer": "addr_test1qfac", "scriptHash": "deadbeef"}, + } + out, err := s.EnhancePaymentRequirements( + context.Background(), r, supported, []string{"scriptHash"}) + if err != nil { + t.Fatal(err) + } + // Server-side requirements always win (assetTransferMethod stayed "default"). + if out.Extra["assetTransferMethod"] != "default" { + t.Errorf("server extras must not be overridden: %v", out.Extra) + } + // SupportedKind extras are mixed in for keys not already present. + if out.Extra["feePayer"] != "addr_test1qfac" { + t.Errorf("expected feePayer carried through: %v", out.Extra) + } + // Requested extension key gets propagated. + if out.Extra["scriptHash"] != "deadbeef" { + t.Errorf("expected scriptHash carried through: %v", out.Extra) + } +} + +func TestGetAssetDecimals(t *testing.T) { + s := NewExactCardanoScheme() + if d := s.GetAssetDecimals(cardano.LovelaceAsset, x402.Network(cardano.CardanoPreprod)); d != 6 { + t.Errorf("expected 6 decimals, got %d", d) + } +} diff --git a/go/mechanisms/cardano/types.go b/go/mechanisms/cardano/types.go new file mode 100644 index 0000000000..fc31533c7c --- /dev/null +++ b/go/mechanisms/cardano/types.go @@ -0,0 +1,84 @@ +package cardano + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" +) + +// ExactCardanoPayload is the inner Cardano-specific payload carried inside +// a v2 PaymentPayload's `payload` map. +// +// Wire format (JSON object inside paymentPayload.payload): +// +// { "transaction": "", "nonce": "#" } +// +// transaction is a base64-encoded, fully signed Cardano CBOR transaction. +// nonce is a UTXO reference (txHashHex#index) that MUST also appear as a +// transaction input — the facilitator uses it to enforce uniqueness and +// replay protection (rule 5 of the spec). +type ExactCardanoPayload struct { + Transaction string `json:"transaction"` + Nonce string `json:"nonce"` +} + +// ToMap returns a JSON-shaped map suitable for stuffing into +// types.PaymentPayload.Payload. +func (p ExactCardanoPayload) ToMap() map[string]interface{} { + return map[string]interface{}{ + "transaction": p.Transaction, + "nonce": p.Nonce, + } +} + +// ExactCardanoPayloadFromMap extracts a typed Cardano payload from the +// generic map form used by types.PaymentPayload. +// +// Returns an error if either field is missing or empty. +func ExactCardanoPayloadFromMap(raw map[string]interface{}) (ExactCardanoPayload, error) { + if raw == nil { + return ExactCardanoPayload{}, errors.New("cardano payload is nil") + } + tx, _ := raw["transaction"].(string) + if tx == "" { + return ExactCardanoPayload{}, errors.New("cardano payload is missing a transaction string") + } + nonce, _ := raw["nonce"].(string) + if nonce == "" { + return ExactCardanoPayload{}, errors.New("cardano payload is missing a nonce string") + } + return ExactCardanoPayload{Transaction: tx, Nonce: nonce}, nil +} + +// PaymentResponseHeader is the body of the PAYMENT-RESPONSE response header +// emitted on a successful settlement (base64-encoded JSON in the header). +// +// Wire format: +// +// { +// "success": true, +// "transaction": "", +// "network": "cardano:preprod", +// "extensions": { "status": "confirmed" | "mempool" }, +// "errorReason": "Utxo not found in utxo set" +// } +// +// ErrorReason is only populated when Success is false. +type PaymentResponseHeader struct { + Success bool `json:"success"` + Transaction string `json:"transaction,omitempty"` + Network string `json:"network,omitempty"` + Extensions map[string]interface{} `json:"extensions,omitempty"` + ErrorReason string `json:"errorReason,omitempty"` +} + +// EncodeHeader returns the base64-encoded JSON suitable for the +// PAYMENT-RESPONSE response header. +func (h PaymentResponseHeader) EncodeHeader() (string, error) { + body, err := json.Marshal(h) + if err != nil { + return "", fmt.Errorf("encoding PAYMENT-RESPONSE: %w", err) + } + return base64.StdEncoding.EncodeToString(body), nil +} diff --git a/go/mechanisms/cardano/types_test.go b/go/mechanisms/cardano/types_test.go new file mode 100644 index 0000000000..6ba368ca10 --- /dev/null +++ b/go/mechanisms/cardano/types_test.go @@ -0,0 +1,109 @@ +package cardano + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +func TestExactCardanoPayloadFromMap(t *testing.T) { + good := map[string]interface{}{ + "transaction": "hKQA2QECgoJYIHCLvww0", + "nonce": "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b#3", + } + p, err := ExactCardanoPayloadFromMap(good) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if p.Transaction == "" || p.Nonce == "" { + t.Fatalf("fields not populated: %+v", p) + } + + // Missing transaction + if _, err := ExactCardanoPayloadFromMap(map[string]interface{}{"nonce": "x#0"}); err == nil { + t.Error("expected error for missing transaction") + } + // Missing nonce + if _, err := ExactCardanoPayloadFromMap(map[string]interface{}{"transaction": "abc"}); err == nil { + t.Error("expected error for missing nonce") + } + // Empty map + if _, err := ExactCardanoPayloadFromMap(map[string]interface{}{}); err == nil { + t.Error("expected error for empty map") + } + // Nil + if _, err := ExactCardanoPayloadFromMap(nil); err == nil { + t.Error("expected error for nil") + } +} + +func TestExactCardanoPayloadToMapRoundTrip(t *testing.T) { + original := ExactCardanoPayload{ + Transaction: "hKQA2QECgoJYIHCLvww0", + Nonce: "abcdef00112233445566778899aabbccddeeff112233445566778899aabbccdd#7", + } + m := original.ToMap() + got, err := ExactCardanoPayloadFromMap(m) + if err != nil { + t.Fatal(err) + } + if got != original { + t.Errorf("round-trip mismatch: got %+v want %+v", got, original) + } +} + +func TestPaymentResponseHeaderEncoding(t *testing.T) { + h := PaymentResponseHeader{ + Success: true, + Transaction: "abc123", + Network: CardanoPreprod, + Extensions: map[string]interface{}{"status": "confirmed"}, + } + b64, err := h.EncodeHeader() + if err != nil { + t.Fatal(err) + } + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatal(err) + } + var decoded PaymentResponseHeader + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if !decoded.Success || decoded.Transaction != "abc123" { + t.Errorf("decoded mismatch: %+v", decoded) + } + if decoded.Network != CardanoPreprod { + t.Errorf("network not preserved: %s", decoded.Network) + } + if decoded.Extensions["status"] != "confirmed" { + t.Errorf("extensions not preserved: %v", decoded.Extensions) + } +} + +func TestPaymentResponseHeaderEncodingFailure(t *testing.T) { + h := PaymentResponseHeader{ + Success: false, + Network: CardanoPreprod, + ErrorReason: "Utxo not found in utxo set", + } + b64, err := h.EncodeHeader() + if err != nil { + t.Fatal(err) + } + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatal(err) + } + var decoded PaymentResponseHeader + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Success { + t.Errorf("expected success=false: %+v", decoded) + } + if decoded.ErrorReason != "Utxo not found in utxo set" { + t.Errorf("errorReason not preserved: %s", decoded.ErrorReason) + } +} diff --git a/go/mechanisms/cardano/utils.go b/go/mechanisms/cardano/utils.go new file mode 100644 index 0000000000..e2f0da0301 --- /dev/null +++ b/go/mechanisms/cardano/utils.go @@ -0,0 +1,110 @@ +package cardano + +import ( + "fmt" + "math/big" + "regexp" + "strconv" + "strings" +) + +var ( + assetRE = regexp.MustCompile(CardanoAssetRegex) + utxoRE = regexp.MustCompile(CardanoUTXORefRegex) + addrRE = regexp.MustCompile(CardanoAddressRegex) +) + +// ValidateAssetUnit returns nil when asset is the literal "lovelace" or a +// well-formed `policyId.assetNameHex` pair. +func ValidateAssetUnit(asset string) error { + if !assetRE.MatchString(asset) { + return fmt.Errorf("invalid Cardano asset unit: %s", asset) + } + return nil +} + +// ValidateAddress returns nil when addr is a syntactically valid bech32 +// Cardano address (mainnet or testnet). It does not check the bech32 +// checksum; that is the facilitator's responsibility. +func ValidateAddress(addr string) error { + if !addrRE.MatchString(addr) { + return fmt.Errorf("invalid Cardano address: %s", addr) + } + return nil +} + +// ValidateUTXORef returns nil when ref looks like `txHashHex#index`. +func ValidateUTXORef(ref string) error { + if !utxoRE.MatchString(ref) { + return fmt.Errorf("invalid Cardano UTXO reference: %s", ref) + } + return nil +} + +// ParseUTXORef splits `txHashHex#index` into its parts. +func ParseUTXORef(ref string) (txHash string, index int, err error) { + if err = ValidateUTXORef(ref); err != nil { + return "", 0, err + } + idx := strings.IndexByte(ref, '#') + txHash = strings.ToLower(ref[:idx]) + index, err = strconv.Atoi(ref[idx+1:]) + return +} + +// ParseAssetUnit splits an asset unit string. For lovelace both parts are empty. +func ParseAssetUnit(asset string) (policyID, assetNameHex string, err error) { + if err = ValidateAssetUnit(asset); err != nil { + return "", "", err + } + if strings.EqualFold(asset, LovelaceAsset) { + return "", "", nil + } + dot := strings.IndexByte(asset, '.') + return strings.ToLower(asset[:dot]), strings.ToLower(asset[dot+1:]), nil +} + +// ConvertToTokenAmount converts a decimal-string amount (e.g. "0.10") into +// the asset's smallest atomic unit. Uses arbitrary-precision arithmetic. +func ConvertToTokenAmount(decimalAmount string, decimals int) (string, error) { + r, ok := new(big.Rat).SetString(strings.TrimSpace(decimalAmount)) + if !ok { + return "", fmt.Errorf("invalid amount: %s", decimalAmount) + } + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + r.Mul(r, new(big.Rat).SetInt(scale)) + if !r.IsInt() { + return "", fmt.Errorf( + "amount %s has more decimals than allowed (%d)", decimalAmount, decimals) + } + return r.Num().String(), nil +} + +// ParseMoneyToDecimal accepts a Money value (string with optional "$" +// prefix and currency suffix, or a numeric type) and returns the decimal +// component as a string. +func ParseMoneyToDecimal(money interface{}) (string, error) { + switch v := money.(type) { + case string: + clean := strings.TrimSpace(v) + clean = strings.TrimPrefix(clean, "$") + // Strip a trailing currency code (USD / USDC / USDM, case-insensitive). + for _, suffix := range []string{" USD", " USDC", " USDM"} { + if strings.HasSuffix(strings.ToUpper(clean), suffix) { + clean = strings.TrimSpace(clean[:len(clean)-len(suffix)]) + break + } + } + return strings.TrimSpace(clean), nil + case int: + return strconv.Itoa(v), nil + case int64: + return strconv.FormatInt(v, 10), nil + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), nil + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32), nil + default: + return "", fmt.Errorf("invalid money value: %T", money) + } +} diff --git a/go/mechanisms/cardano/utils_test.go b/go/mechanisms/cardano/utils_test.go new file mode 100644 index 0000000000..a2f0e42b20 --- /dev/null +++ b/go/mechanisms/cardano/utils_test.go @@ -0,0 +1,115 @@ +package cardano + +import "testing" + +func TestParseUTXORef(t *testing.T) { + tx := "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b" + hash, idx, err := ParseUTXORef(tx + "#3") + if err != nil { + t.Fatal(err) + } + if hash != tx { + t.Errorf("tx hash mismatch: %s", hash) + } + if idx != 3 { + t.Errorf("index mismatch: %d", idx) + } + // Uppercase is normalised to lower. + hash, _, err = ParseUTXORef("ABCDEF" + tx[6:] + "#0") + if err != nil { + t.Fatal(err) + } + if hash[0] != 'a' { + t.Errorf("expected lowercased tx hash, got %q", hash) + } + if _, _, err := ParseUTXORef("invalid"); err == nil { + t.Error("expected error on missing #index") + } +} + +func TestParseAssetUnit(t *testing.T) { + // Lovelace returns empty parts. + policy, name, err := ParseAssetUnit("lovelace") + if err != nil || policy != "" || name != "" { + t.Errorf("expected empty parts for lovelace; got policy=%q name=%q err=%v", + policy, name, err) + } + // Native token splits. + policy, name, err = ParseAssetUnit(USDMPreprodAsset) + if err != nil { + t.Fatal(err) + } + if policy != USDMPreprodPolicyID { + t.Errorf("policy mismatch: %s", policy) + } + if name != USDMAssetNameHex { + t.Errorf("name mismatch: %s", name) + } + if _, _, err := ParseAssetUnit("nonsense"); err == nil { + t.Error("expected error for invalid asset") + } +} + +func TestConvertToTokenAmount(t *testing.T) { + cases := []struct { + in string + decimals int + want string + }{ + {"1", 6, "1000000"}, + {"1.50", 6, "1500000"}, + {"0.000001", 6, "1"}, + {"5", 6, "5000000"}, + } + for _, c := range cases { + got, err := ConvertToTokenAmount(c.in, c.decimals) + if err != nil { + t.Errorf("%s: unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("ConvertToTokenAmount(%q,%d)=%q want %q", c.in, c.decimals, got, c.want) + } + } + // Too many decimals → error. + if _, err := ConvertToTokenAmount("0.0000001", 6); err == nil { + t.Error("expected error when amount has more decimals than allowed") + } +} + +func TestParseMoneyToDecimal(t *testing.T) { + cases := map[interface{}]string{ + "$1.50": "1.50", + "1.50": "1.50", + " 1.5 USDM ": "1.5", + "3 USD": "3", + 1.50: "1.5", + 2: "2", + int64(7): "7", + } + for in, want := range cases { + got, err := ParseMoneyToDecimal(in) + if err != nil { + t.Errorf("%v: unexpected error: %v", in, err) + continue + } + if got != want { + t.Errorf("ParseMoneyToDecimal(%v)=%q want %q", in, got, want) + } + } + if _, err := ParseMoneyToDecimal(struct{}{}); err == nil { + t.Error("expected error for unsupported type") + } +} + +func TestValidateAddress(t *testing.T) { + if err := ValidateAddress("addr_test1qzfoo"); err != nil { + t.Errorf("expected valid testnet address, got %v", err) + } + if err := ValidateAddress("addr1qfoo"); err != nil { + t.Errorf("expected valid mainnet address, got %v", err) + } + if err := ValidateAddress("0xinvalid"); err == nil { + t.Error("expected invalid address rejected") + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoAccepts.java b/java/src/main/java/org/x402/cardano/CardanoAccepts.java new file mode 100644 index 0000000000..b3dde368be --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoAccepts.java @@ -0,0 +1,74 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.HashMap; +import java.util.Map; + +/** + * One entry in the {@code accepts[]} array of a V2 402 challenge. + * + *

Wire format: + *

{@code
+ * {
+ *   "scheme":  "exact",
+ *   "network": "cardano:preprod",
+ *   "payTo":   "addr_test1qx...",
+ *   "amount":  "5000000",
+ *   "asset":   "lovelace",
+ *   "maxTimeoutSeconds": 300,
+ *   "extra":   { "assetTransferMethod": "default" }
+ * }
+ * }
+ * + *

This is structurally a flattened {@link CardanoPaymentRequirements} — + * separating it as its own type keeps the 402 response shape decoupled from + * the facilitator's request shape and matches the TS/Python references. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CardanoAccepts { + + /** Scheme identifier — always {@code "exact"} for Cardano. */ + public String scheme = CardanoConstants.SCHEME_EXACT; + + /** x402 Cardano network identifier (e.g. {@code "cardano:preprod"}). */ + public String network; + + /** Bech32 recipient address. */ + public String payTo; + + /** Amount in the asset's smallest unit (decimal string). */ + public String amount; + + /** Asset unit (see {@link CardanoConstants#CARDANO_ASSET_REGEX}). */ + public String asset; + + /** Maximum payment validity in seconds. */ + public int maxTimeoutSeconds = CardanoConstants.DEFAULT_MAX_TIMEOUT_SECONDS; + + /** Mechanism-specific extras (e.g. {@code assetTransferMethod}). */ + public Map extra = new HashMap<>(); + + /** Default no-arg constructor (required by Jackson). */ + public CardanoAccepts() {} + + /** + * Build a default-method Cardano accepts entry. + * + * @param network x402 network identifier + * @param payTo bech32 recipient address + * @param amount amount in smallest unit + * @param asset asset unit + * @return a fully populated accepts entry + */ + public static CardanoAccepts forDefaultTransfer( + String network, String payTo, String amount, String asset) { + CardanoAccepts a = new CardanoAccepts(); + a.network = network; + a.payTo = payTo; + a.amount = amount; + a.asset = asset; + a.extra.put( + "assetTransferMethod", CardanoConstants.ASSET_TRANSFER_METHOD_DEFAULT); + return a; + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoConstants.java b/java/src/main/java/org/x402/cardano/CardanoConstants.java new file mode 100644 index 0000000000..8c1a995cee --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoConstants.java @@ -0,0 +1,205 @@ +package org.x402.cardano; + +/** + * Constants for the x402 Cardano `exact` mechanism (V2 protocol). + * + *

Mirrors the constants defined in the TypeScript and Python reference + * implementations so error codes, network identifiers, and asset markers are + * identical across all three SDKs. See + * {@code python/x402/mechanisms/cardano/constants.py} and + * {@code typescript/packages/mechanisms/cardano/src/constants.ts} for the + * canonical definitions. + * + *

This class is final and not instantiable; access constants statically. + */ +public final class CardanoConstants { + + /** Scheme identifier for the `exact` payment scheme. */ + public static final String SCHEME_EXACT = "exact"; + + // ----- Network identifiers (per spec, NOT canonical CAIP-2) ------------ + + /** x402 network identifier for Cardano mainnet. */ + public static final String CARDANO_MAINNET = "cardano:mainnet"; + + /** x402 network identifier for Cardano preprod testnet. */ + public static final String CARDANO_PREPROD = "cardano:preprod"; + + /** x402 network identifier for Cardano preview testnet. */ + public static final String CARDANO_PREVIEW = "cardano:preview"; + + // ----- Cardano network IDs encoded in tx body -------------------------- + + /** Cardano body network_id for mainnet. */ + public static final int CARDANO_NETWORK_ID_MAINNET = 1; + + /** Cardano body network_id for any testnet (preprod, preview). */ + public static final int CARDANO_NETWORK_ID_TESTNET = 0; + + // ----- Asset constants ------------------------------------------------- + + /** + * Asset identifier for native ADA. The facilitator special-cases this + * value: lovelace lives in an output's `coin` field rather than in the + * multi-asset map, so verification compares against {@code output.coin} + * when the asset string is exactly {@code "lovelace"}. + */ + public static final String LOVELACE_ASSET = "lovelace"; + + /** Default decimals for USDM (and most stablecoins on Cardano). */ + public static final int USDM_DEFAULT_DECIMALS = 6; + + /** USDM policy ID on mainnet. */ + public static final String USDM_MAINNET_POLICY_ID = + "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad"; + + /** USDM policy ID on preprod testnet. */ + public static final String USDM_PREPROD_POLICY_ID = + "16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde"; + + /** USDM CIP-68 reference asset name (333 prefix + 'USDM'). */ + public static final String USDM_ASSET_NAME_HEX = "0014df105553444d"; + + /** Mainnet USDM asset unit ({@code policyId.assetNameHex}). */ + public static final String USDM_MAINNET_ASSET = + USDM_MAINNET_POLICY_ID + "." + USDM_ASSET_NAME_HEX; + + /** Preprod USDM asset unit ({@code policyId.assetNameHex}). */ + public static final String USDM_PREPROD_ASSET = + USDM_PREPROD_POLICY_ID + "." + USDM_ASSET_NAME_HEX; + + // ----- Validation regexes --------------------------------------------- + + /** + * Asset unit validation regex. Accepts the literal {@code "lovelace"} + * (native ADA) or {@code policyId.assetNameHex} for native tokens. + */ + public static final String CARDANO_ASSET_REGEX = + "^(lovelace|[0-9a-fA-F]{56}\\.[0-9a-fA-F]{0,64})$"; + + /** Cardano bech32 address validation (mainnet or testnet). */ + public static final String CARDANO_ADDRESS_REGEX = "^(addr1|addr_test1)[0-9a-z]+$"; + + /** UTXO reference validation regex ({@code txHashHex#index}). */ + public static final String CARDANO_UTXO_REF_REGEX = "^[0-9a-fA-F]{64}#\\d+$"; + + // ----- assetTransferMethod markers ------------------------------------ + + /** Default asset-transfer method (sender pays + signs). */ + public static final String ASSET_TRANSFER_METHOD_DEFAULT = "default"; + + /** Masumi smart-protocol asset-transfer method. */ + public static final String ASSET_TRANSFER_METHOD_MASUMI = "masumi"; + + /** Plutus-script asset-transfer method. */ + public static final String ASSET_TRANSFER_METHOD_SCRIPT = "script"; + + // ----- Error codes (mirrored from spec; identical across SDKs) --------- + + /** Returned when the scheme is not "exact". */ + public static final String ERR_UNSUPPORTED_SCHEME = "unsupported_scheme"; + + /** Generic invalid Cardano payload (decode error, missing fields, etc.). */ + public static final String ERR_INVALID_PAYLOAD = "invalid_exact_cardano_payload"; + + /** Transaction CBOR could not be decoded. */ + public static final String ERR_TRANSACTION_DECODE_FAILED = + "invalid_exact_cardano_payload_transaction_decode_failed"; + + /** Transaction body declares a network_id that disagrees with the network. */ + public static final String ERR_NETWORK_ID_MISMATCH = + "invalid_exact_cardano_payload_network_id_mismatch"; + + /** Transaction's accepted.network differs from required network. */ + public static final String ERR_NETWORK_MISMATCH = "network_mismatch"; + + /** No transaction output pays the required recipient. */ + public static final String ERR_RECIPIENT_MISMATCH = + "invalid_exact_cardano_payload_recipient_mismatch"; + + /** Recipient output exists but does not carry the required asset. */ + public static final String ERR_ASSET_MISMATCH = + "invalid_exact_cardano_payload_asset_mismatch"; + + /** Recipient + asset matched, but the amount is below the requirement. */ + public static final String ERR_AMOUNT_INSUFFICIENT = + "invalid_exact_cardano_payload_amount_insufficient"; + + /** Nonce is not a valid UTXO reference (txHashHex#index). */ + public static final String ERR_NONCE_INVALID = + "invalid_exact_cardano_payload_nonce_invalid"; + + /** Nonce UTXO reference is not present among the transaction inputs. */ + public static final String ERR_NONCE_NOT_IN_INPUTS = + "invalid_exact_cardano_payload_nonce_not_in_inputs"; + + /** Nonce UTXO is unknown or already spent on chain. */ + public static final String ERR_NONCE_NOT_ON_CHAIN = + "invalid_exact_cardano_payload_nonce_not_on_chain"; + + /** Transaction's TTL slot is in the past. */ + public static final String ERR_TTL_EXPIRED = "invalid_exact_cardano_payload_ttl_expired"; + + /** Transaction's validity start slot is still in the future. */ + public static final String ERR_VALIDITY_NOT_YET_VALID = + "invalid_exact_cardano_payload_not_yet_valid"; + + /** Facilitator failed to query Blockfrost / the chain. */ + public static final String ERR_CHAIN_LOOKUP_FAILED = + "exact_cardano_facilitator_chain_lookup_failed"; + + /** Submission to chain failed. */ + public static final String ERR_SETTLEMENT_FAILED = "exact_cardano_settlement_failed"; + + /** Submission accepted but the chain has not yet confirmed. */ + public static final String ERR_SETTLEMENT_NOT_CONFIRMED = + "exact_cardano_settlement_not_confirmed"; + + /** Repeated settlement attempt with the same transaction. */ + public static final String ERR_DUPLICATE_SETTLEMENT = "duplicate_settlement"; + + /** Reconstructed script address disagrees with declared payTo. */ + public static final String ERR_SCRIPT_ADDRESS_MISMATCH = + "invalid_exact_cardano_payload_script_address_mismatch"; + + /** Optional Cardano SDK (pycardano / serialization-lib) is missing. */ + public static final String ERR_CARDANO_SDK_MISSING = "exact_cardano_sdk_missing"; + + /** Transaction has zero witnesses (vkey + script). */ + public static final String ERR_TRANSACTION_UNSIGNED = + "invalid_exact_cardano_payload_unsigned"; + + /** Default {@code maxTimeoutSeconds} for Cardano payment requirements. */ + public static final int DEFAULT_MAX_TIMEOUT_SECONDS = 300; + + private CardanoConstants() {} + + /** + * Return the Cardano network_id for a given x402 network identifier. + * + * @param network x402 network identifier (e.g. {@code "cardano:preprod"}) + * @return 1 for mainnet, 0 for testnets + * @throws IllegalArgumentException when the network is not a Cardano network + */ + public static int getNetworkId(String network) { + if (CARDANO_MAINNET.equals(network)) { + return CARDANO_NETWORK_ID_MAINNET; + } + if (CARDANO_PREPROD.equals(network) || CARDANO_PREVIEW.equals(network)) { + return CARDANO_NETWORK_ID_TESTNET; + } + throw new IllegalArgumentException("Unsupported Cardano network: " + network); + } + + /** + * Return whether the supplied identifier names a supported Cardano network. + * + * @param network identifier to check (may be null) + * @return true if the network is mainnet, preprod, or preview + */ + public static boolean isCardanoNetwork(String network) { + return CARDANO_MAINNET.equals(network) + || CARDANO_PREPROD.equals(network) + || CARDANO_PREVIEW.equals(network); + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoFacilitator.java b/java/src/main/java/org/x402/cardano/CardanoFacilitator.java new file mode 100644 index 0000000000..d5a6280657 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoFacilitator.java @@ -0,0 +1,50 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; + +/** + * Facilitator-side contract for the Cardano `exact` mechanism. + * + *

Resource servers depend on this interface (not the concrete + * {@link CardanoFacilitatorClient}) so they can be wired against an + * in-process implementation in tests, or replaced with a custom transport + * (e.g. gRPC, message bus) without changing application code. + */ +public interface CardanoFacilitator { + + /** + * Verify a payment. + * + * @param payload payment payload decoded from the {@code PAYMENT-SIGNATURE} header + * @param requirements canonical, server-supplied requirements + * @return the verify response + * @throws IOException on transport failure + * @throws InterruptedException if the calling thread is interrupted + */ + CardanoVerifyResponse verify( + CardanoPaymentPayload payload, CardanoPaymentRequirements requirements) + throws IOException, InterruptedException; + + /** + * Settle (broadcast) a previously verified payment. + * + * @param payload same payload submitted to {@link #verify} + * @param requirements canonical requirements + * @return the settle response + * @throws IOException on transport failure + * @throws InterruptedException if the calling thread is interrupted + */ + CardanoSettleResponse settle( + CardanoPaymentPayload payload, CardanoPaymentRequirements requirements) + throws IOException, InterruptedException; + + /** + * Discover what the facilitator supports. + * + * @return raw {@code /supported} JSON tree + * @throws IOException on transport failure + * @throws InterruptedException if the calling thread is interrupted + */ + JsonNode supported() throws IOException, InterruptedException; +} diff --git a/java/src/main/java/org/x402/cardano/CardanoFacilitatorClient.java b/java/src/main/java/org/x402/cardano/CardanoFacilitatorClient.java new file mode 100644 index 0000000000..bd31da48d2 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoFacilitatorClient.java @@ -0,0 +1,179 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.databind.JsonNode; +import org.x402.util.Json; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * HTTP client for an x402 V2 facilitator that supports the Cardano `exact` + * scheme. + * + *

Both calls submit JSON of the form + *

{@code
+ * {
+ *   "x402Version": 2,
+ *   "paymentPayload":      { ...CardanoPaymentPayload... },
+ *   "paymentRequirements": { ...CardanoPaymentRequirements... }
+ * }
+ * }
+ * and parse the response body as {@link CardanoVerifyResponse} or + * {@link CardanoSettleResponse} respectively. + * + *

Use this client from a Cardano-protected resource server (see + * {@link CardanoPaymentFilter}); a typical deployment runs the facilitator + * as a separate service backed by a chain provider such as Blockfrost. + */ +public class CardanoFacilitatorClient implements CardanoFacilitator { + + private final HttpClient http; + private final String baseUrl; + + /** + * Create a facilitator client with sensible defaults (5s connect timeout, + * HTTP/1.1). + * + *

HTTP/1.1 is forced (instead of the JDK's default of HTTP/2) because + * the reference Python facilitator runs on uvicorn, which does not handle + * the {@code Upgrade: h2c} negotiation that JDK HttpClient emits on + * cleartext HTTP. Leaving the default would cause uvicorn to log + * "Unsupported upgrade request" and silently drop the request body, + * surfacing as a Pydantic 422 about a missing body. + * + * @param baseUrl base URL of the facilitator (trailing slash trimmed) + */ + public CardanoFacilitatorClient(String baseUrl) { + this( + baseUrl, + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .version(HttpClient.Version.HTTP_1_1) + .build()); + } + + /** + * Create a facilitator client with a caller-supplied {@link HttpClient}. + * + * @param baseUrl base URL of the facilitator + * @param http preconfigured HTTP client (must not be null) + */ + public CardanoFacilitatorClient(String baseUrl, HttpClient http) { + Objects.requireNonNull(baseUrl, "baseUrl"); + Objects.requireNonNull(http, "http"); + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.http = http; + } + + /** + * Call {@code POST /verify} on the facilitator. + * + * @param payload payment payload decoded from the {@code PAYMENT-SIGNATURE} header + * @param requirements requirements being fulfilled (server-supplied, canonical) + * @return the facilitator's verify response + * @throws IOException on transport / non-2xx response + * @throws InterruptedException if the calling thread is interrupted + */ + @Override + public CardanoVerifyResponse verify( + CardanoPaymentPayload payload, CardanoPaymentRequirements requirements) + throws IOException, InterruptedException { + String body = Json.MAPPER.writeValueAsString(buildEnvelope(payload, requirements)); + HttpResponse response = post("/verify", body); + if (response.statusCode() / 100 != 2) { + throw new IOException( + "Facilitator /verify returned HTTP " + + response.statusCode() + + ": " + + response.body()); + } + return Json.MAPPER.readValue(response.body(), CardanoVerifyResponse.class); + } + + /** + * Call {@code POST /settle} on the facilitator. + * + * @param payload payment payload (same as verified) + * @param requirements requirements being fulfilled + * @return the facilitator's settle response + * @throws IOException on transport / non-2xx response + * @throws InterruptedException if the calling thread is interrupted + */ + @Override + public CardanoSettleResponse settle( + CardanoPaymentPayload payload, CardanoPaymentRequirements requirements) + throws IOException, InterruptedException { + String body = Json.MAPPER.writeValueAsString(buildEnvelope(payload, requirements)); + HttpResponse response = post("/settle", body); + if (response.statusCode() / 100 != 2) { + throw new IOException( + "Facilitator /settle returned HTTP " + + response.statusCode() + + ": " + + response.body()); + } + return Json.MAPPER.readValue(response.body(), CardanoSettleResponse.class); + } + + /** + * Call {@code GET /supported} on the facilitator and return the raw JSON. + * + *

Resource servers can call this on startup to verify the facilitator + * supports the Cardano `exact` scheme on the requested network. + * + * @return parsed JSON tree of the {@code /supported} response + * @throws IOException on transport / non-2xx response + * @throws InterruptedException if the calling thread is interrupted + */ + @Override + public JsonNode supported() throws IOException, InterruptedException { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/supported")) + .timeout(Duration.ofSeconds(30)) + .version(HttpClient.Version.HTTP_1_1) + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IOException( + "Facilitator /supported returned HTTP " + + response.statusCode() + + ": " + + response.body()); + } + return Json.MAPPER.readTree(response.body()); + } + + /* --------------------------------------------------------------- */ + + private Map buildEnvelope( + CardanoPaymentPayload payload, CardanoPaymentRequirements requirements) { + Map env = new LinkedHashMap<>(); + env.put("x402Version", 2); + env.put("paymentPayload", payload); + env.put("paymentRequirements", requirements); + return env; + } + + private HttpResponse post(String path, String body) + throws IOException, InterruptedException { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .timeout(Duration.ofSeconds(60)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoPaymentFilter.java b/java/src/main/java/org/x402/cardano/CardanoPaymentFilter.java new file mode 100644 index 0000000000..d44049e29f --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoPaymentFilter.java @@ -0,0 +1,339 @@ +package org.x402.cardano; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.x402.util.Json; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Servlet filter that enforces x402 V2 Cardano payments on selected routes. + * + *

Wire-protocol responsibilities: + *

    + *
  1. If the request hits a protected route and carries no + * {@code PAYMENT-SIGNATURE} header → return HTTP 402 with a + * {@link CardanoPaymentRequired} JSON body and the same body + * base64-encoded in the {@code PAYMENT-REQUIRED} response header.
  2. + *
  3. If a header is present, decode it, post {@code paymentPayload} + + * {@code paymentRequirements} to the facilitator's {@code /verify}. + * On failure return 402 with the facilitator's reason code.
  4. + *
  5. On verify success, invoke the downstream handler.
  6. + *
  7. If the handler returns < 400, call {@code /settle}. Success → set + * a {@code PAYMENT-RESPONSE} header (base64 JSON). Failure → 402.
  8. + *
+ * + *

Path matching is exact and case-sensitive on + * {@link HttpServletRequest#getRequestURI()}. Paths not present in the + * route map are passed through with no payment check. + * + *

Thread-safe: the filter holds an immutable route map and stateless + * dependencies (the facilitator client is itself thread-safe under the + * standard Java HttpClient). + * + *

Example: + *

{@code
+ * Map routes = Map.of(
+ *     "/premium", RouteConfig.forDefault(
+ *         CardanoConstants.CARDANO_PREPROD,
+ *         "addr_test1q...", "5000000",
+ *         CardanoConstants.LOVELACE_ASSET,
+ *         "Cardano premium endpoint (5 tADA)",
+ *         "application/json"));
+ *
+ * CardanoFacilitatorClient facilitator =
+ *     new CardanoFacilitatorClient("http://facilitator:8080");
+ * CardanoPaymentFilter filter = new CardanoPaymentFilter(facilitator, routes);
+ * }
+ */ +public class CardanoPaymentFilter implements Filter { + + /** Request header carrying the base64-encoded V2 PaymentPayload. */ + public static final String HDR_PAYMENT_SIGNATURE = "PAYMENT-SIGNATURE"; + + /** Response header carrying the base64-encoded V2 PaymentRequired body. */ + public static final String HDR_PAYMENT_REQUIRED = "PAYMENT-REQUIRED"; + + /** Response header carrying the base64-encoded settlement response. */ + public static final String HDR_PAYMENT_RESPONSE = "PAYMENT-RESPONSE"; + + private final CardanoFacilitator facilitator; + private final Map routes; + + /** + * Create the filter. + * + * @param facilitator V2 Cardano facilitator (typically a + * {@link CardanoFacilitatorClient}, but any implementation works) + * @param routes immutable route map: URI path → {@link RouteConfig} + */ + public CardanoPaymentFilter( + CardanoFacilitator facilitator, Map routes) { + this.facilitator = Objects.requireNonNull(facilitator, "facilitator"); + this.routes = Map.copyOf(Objects.requireNonNull(routes, "routes")); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + if (!(req instanceof HttpServletRequest) || !(res instanceof HttpServletResponse)) { + chain.doFilter(req, res); + return; + } + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + // Always allow CORS preflight through. + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + chain.doFilter(req, res); + return; + } + + RouteConfig route = routes.get(request.getRequestURI()); + if (route == null) { + chain.doFilter(req, res); + return; + } + + String header = request.getHeader(HDR_PAYMENT_SIGNATURE); + if (header == null || header.isEmpty()) { + respondWithChallenge(request, response, route, "Payment required"); + return; + } + + // ---- Decode & verify ----------------------------------------------- + CardanoPaymentPayload payload; + try { + payload = CardanoPaymentPayload.fromHeader(header); + } catch (IllegalArgumentException ex) { + respondWithChallenge(request, response, route, CardanoConstants.ERR_INVALID_PAYLOAD); + return; + } + // SECURITY: the facilitator is given the SERVER-supplied requirements + // as the canonical truth, NOT what the client put in `accepted`. + CardanoPaymentRequirements requirements = toRequirements(route.accepts); + + CardanoVerifyResponse verify; + try { + verify = facilitator.verify(payload, requirements); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + writeServerError(response, "Payment verification interrupted"); + return; + } catch (IOException ex) { + writeServerError(response, "Payment verification failed: " + ex.getMessage()); + return; + } catch (RuntimeException ex) { + writeServerError(response, "Internal server error during payment verification"); + return; + } + if (!verify.isValid) { + String reason = + verify.invalidReason != null + ? verify.invalidReason + : "verification_failed"; + respondWithChallenge(request, response, route, reason); + return; + } + + // ---- Run protected handler ----------------------------------------- + chain.doFilter(req, res); + + // ---- Settle (only on a successful application response) ------------ + if (response.getStatus() >= 400) { + return; + } + + CardanoSettleResponse settle; + try { + settle = facilitator.settle(payload, requirements); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + tryRespondAfterCommit(response, route, "settlement interrupted"); + return; + } catch (IOException ex) { + tryRespondAfterCommit(response, route, "settlement error: " + ex.getMessage()); + return; + } catch (RuntimeException ex) { + tryRespondAfterCommit( + response, route, "settlement error: " + ex.getClass().getSimpleName()); + return; + } + + if (!settle.success) { + String reason = + settle.errorReason != null + ? settle.errorReason + : CardanoConstants.ERR_SETTLEMENT_FAILED; + tryRespondAfterCommit(response, route, reason); + return; + } + + // Success: emit the PAYMENT-RESPONSE header so wallet UIs can show + // the resulting tx hash and confirmation status. + String headerValue = PaymentResponseHeader.fromSettle(settle).encode(); + response.setHeader(HDR_PAYMENT_RESPONSE, headerValue); + appendExposedHeader(response, HDR_PAYMENT_RESPONSE); + } + + /* ------------------------------------------------------------------ */ + /* helpers */ + /* ------------------------------------------------------------------ */ + + /** + * Compose a 402 challenge body for the given route. + * + * @param request inbound request (used to populate {@link ResourceInfo#url}) + * @param route the matched route + * @param error error code or human-readable message + * @return the V2 PaymentRequired response body + */ + static CardanoPaymentRequired buildChallenge( + HttpServletRequest request, RouteConfig route, String error) { + CardanoPaymentRequired body = new CardanoPaymentRequired(); + body.error = error; + body.resource = new ResourceInfo( + request.getRequestURL().toString(), route.description, route.mimeType); + body.accepts.add(route.accepts); + return body; + } + + /** Convert a public {@link CardanoAccepts} into facilitator-shaped requirements. */ + static CardanoPaymentRequirements toRequirements(CardanoAccepts a) { + CardanoPaymentRequirements r = new CardanoPaymentRequirements(); + r.scheme = a.scheme; + r.network = a.network; + r.asset = a.asset; + r.amount = a.amount; + r.payTo = a.payTo; + r.maxTimeoutSeconds = a.maxTimeoutSeconds; + r.extra = new HashMap<>(a.extra); + return r; + } + + private void respondWithChallenge( + HttpServletRequest request, + HttpServletResponse response, + RouteConfig route, + String error) + throws IOException { + CardanoPaymentRequired body = buildChallenge(request, route, error); + String json = Json.MAPPER.writeValueAsString(body); + + response.setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + response.setContentType("application/json"); + // Echo the same body in the PAYMENT-REQUIRED header so SPAs that + // can't read the body (e.g. opaque CORS responses) can still show + // the challenge to the user. + String b64 = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + response.setHeader(HDR_PAYMENT_REQUIRED, b64); + appendExposedHeader(response, HDR_PAYMENT_REQUIRED); + + try (PrintWriter out = response.getWriter()) { + out.write(json); + } + } + + private void tryRespondAfterCommit( + HttpServletResponse response, RouteConfig route, String error) { + if (response.isCommitted()) { + // Body already streamed; we cannot turn this into a 402. Best we + // can do is set a PAYMENT-RESPONSE header reporting the failure + // for clients that look at headers. + try { + PaymentResponseHeader h = new PaymentResponseHeader(); + h.success = false; + h.network = route.accepts.network; + response.setHeader(HDR_PAYMENT_RESPONSE, h.encode()); + appendExposedHeader(response, HDR_PAYMENT_RESPONSE); + } catch (RuntimeException ignore) { + // Already-committed response; nothing more to do. + } + return; + } + try { + // Reset body and write a fresh 402 challenge. + response.reset(); + CardanoPaymentRequired body = new CardanoPaymentRequired(); + body.error = error; + body.accepts.add(route.accepts); + String json = Json.MAPPER.writeValueAsString(body); + response.setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + response.setContentType("application/json"); + String b64 = + Base64.getEncoder() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + response.setHeader(HDR_PAYMENT_REQUIRED, b64); + appendExposedHeader(response, HDR_PAYMENT_REQUIRED); + try (PrintWriter out = response.getWriter()) { + out.write(json); + } + } catch (IOException | RuntimeException ignore) { + // Final fallback: just set the status code if writes fail. + response.setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + } + } + + private void writeServerError(HttpServletResponse response, String message) + throws IOException { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + try (PrintWriter out = response.getWriter()) { + out.write("{\"error\":\"" + escapeJsonString(message) + "\"}"); + } + } + + /** Append a header to {@code Access-Control-Expose-Headers} without clobbering existing values. */ + private static void appendExposedHeader(HttpServletResponse response, String headerName) { + String existing = response.getHeader("Access-Control-Expose-Headers"); + if (existing == null || existing.isEmpty()) { + response.setHeader("Access-Control-Expose-Headers", headerName); + return; + } + // Avoid duplicates. + for (String token : existing.split(",")) { + if (token.trim().equalsIgnoreCase(headerName)) { + return; + } + } + response.setHeader("Access-Control-Expose-Headers", existing + ", " + headerName); + } + + /** Minimal JSON-string escape suitable for the small error path above. */ + private static String escapeJsonString(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.toString(); + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoPaymentPayload.java b/java/src/main/java/org/x402/cardano/CardanoPaymentPayload.java new file mode 100644 index 0000000000..6d3da6c91f --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoPaymentPayload.java @@ -0,0 +1,113 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import org.x402.util.Json; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +/** + * V2 PaymentPayload envelope decoded from the {@code PAYMENT-SIGNATURE} + * request header. + * + *

Wire format: + *

{@code
+ * {
+ *   "x402Version": 2,
+ *   "payload":  { "transaction": "...", "nonce": "..." },
+ *   "accepted": { ...payment requirements the client accepted... }
+ * }
+ * }
+ * + *

The header value itself is the base64 encoding of this JSON document. + * Use {@link #fromHeader(String)} to decode and {@link #encodeHeader()} to + * re-encode. + * + *

The {@code payload} field is intentionally a generic map so this class + * can transit any future scheme; for Cardano, decode it with + * {@link ExactCardanoPayload#fromMap(Map)}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CardanoPaymentPayload { + + /** Protocol version (always 2 for V2). */ + public int x402Version = 2; + + /** Scheme-specific payload (Cardano: transaction + nonce). */ + public Map payload; + + /** The PaymentRequirements block the client accepted. */ + public CardanoPaymentRequirements accepted; + + /** Default no-arg constructor (required by Jackson). */ + public CardanoPaymentPayload() {} + + /** + * Decode a {@code PAYMENT-SIGNATURE} header value into a payload object. + * + * @param header base64-encoded JSON + * @return parsed payload + * @throws IllegalArgumentException when the header is not valid base64 + * or the decoded JSON does not match the expected shape + */ + public static CardanoPaymentPayload fromHeader(String header) { + if (header == null || header.isEmpty()) { + throw new IllegalArgumentException("PAYMENT-SIGNATURE header is empty"); + } + byte[] bytes; + try { + bytes = Base64.getDecoder().decode(header); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException( + "PAYMENT-SIGNATURE header is not valid base64", ex); + } + try { + return Json.MAPPER.readValue(bytes, CardanoPaymentPayload.class); + } catch (IOException ex) { + throw new IllegalArgumentException( + "PAYMENT-SIGNATURE header is not a valid V2 PaymentPayload", ex); + } + } + + /** + * Re-encode this payload as a base64 string suitable for the + * {@code PAYMENT-SIGNATURE} header. + * + * @return base64-encoded JSON + */ + public String encodeHeader() { + try { + String json = Json.MAPPER.writeValueAsString(this); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new IllegalStateException("Unable to encode PAYMENT-SIGNATURE header", ex); + } + } + + /** + * Convenience accessor for the inner Cardano payload. + * + * @return the typed Cardano payload + * @throws IllegalArgumentException when the payload map is missing + * required fields + */ + public ExactCardanoPayload getCardanoPayload() { + return ExactCardanoPayload.fromMap(payload); + } + + /** + * Round-trip raw JSON to a {@code JsonNode} for facilitator pass-through. + * + *

The facilitator endpoint expects {@code paymentPayload} as a JSON + * object, not as a base64 header. Use this when forwarding the payload + * after decoding it locally for sanity checks. + * + * @return JSON tree representing this payload + */ + public JsonNode toJsonNode() { + return Json.MAPPER.valueToTree(this); + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoPaymentRequired.java b/java/src/main/java/org/x402/cardano/CardanoPaymentRequired.java new file mode 100644 index 0000000000..1ec6f5eb69 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoPaymentRequired.java @@ -0,0 +1,55 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.ArrayList; +import java.util.List; + +/** + * V2 HTTP 402 challenge body emitted by a Cardano-protected resource server. + * + *

Wire format: + *

{@code
+ * {
+ *   "x402Version": 2,
+ *   "error": "Payment required",
+ *   "resource": {
+ *     "url": "http://localhost:8003/premium",
+ *     "description": "Cardano premium endpoint (5 tADA)",
+ *     "mimeType": "application/json"
+ *   },
+ *   "accepts": [
+ *     {
+ *       "scheme": "exact",
+ *       "network": "cardano:preprod",
+ *       "payTo": "addr_test1qx...",
+ *       "amount": "5000000",
+ *       "asset": "lovelace",
+ *       "maxTimeoutSeconds": 300,
+ *       "extra": { "assetTransferMethod": "default" }
+ *     }
+ *   ]
+ * }
+ * }
+ * + *

The same JSON document — base64-encoded — is also placed in the + * {@code PAYMENT-REQUIRED} response header so SPA clients that cannot read + * the body (e.g. when CORS is restrictive) can still discover the challenge. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CardanoPaymentRequired { + + /** Protocol version (always 2 for V2). */ + public int x402Version = 2; + + /** Error message — defaults to "Payment required" for the initial challenge. */ + public String error; + + /** Information about the resource being purchased (optional but recommended). */ + public ResourceInfo resource; + + /** Acceptable payment configurations. At least one entry is required. */ + public List accepts = new ArrayList<>(); + + /** Default no-arg constructor (required by Jackson). */ + public CardanoPaymentRequired() {} +} diff --git a/java/src/main/java/org/x402/cardano/CardanoPaymentRequirements.java b/java/src/main/java/org/x402/cardano/CardanoPaymentRequirements.java new file mode 100644 index 0000000000..5aa492424c --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoPaymentRequirements.java @@ -0,0 +1,83 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.HashMap; +import java.util.Map; + +/** + * V2 payment requirements for a Cardano `exact` payment. + * + *

This is the wire shape posted both: + *

    + *
  • inside the {@code accepts} array of a 402 challenge, and
  • + *
  • as the {@code paymentRequirements} field of {@code /verify} and + * {@code /settle} calls to a facilitator.
  • + *
+ * + *

Wire format: + *

{@code
+ * {
+ *   "scheme": "exact",
+ *   "network": "cardano:preprod",
+ *   "asset":   "lovelace",
+ *   "amount":  "5000000",
+ *   "payTo":   "addr_test1qx...",
+ *   "maxTimeoutSeconds": 300,
+ *   "extra":   { "assetTransferMethod": "default" }
+ * }
+ * }
+ * + *

The {@code price} object used in the public 402 response (see + * {@link CardanoAccepts}) is flattened into {@code amount} + {@code asset} + * here because that is what the facilitator's {@code /verify} endpoint + * consumes. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CardanoPaymentRequirements { + + /** Scheme identifier — always {@code "exact"} for Cardano. */ + public String scheme = CardanoConstants.SCHEME_EXACT; + + /** x402 Cardano network identifier (e.g. {@code "cardano:preprod"}). */ + public String network; + + /** Asset unit (see {@link CardanoConstants#CARDANO_ASSET_REGEX}). */ + public String asset; + + /** Amount in the asset's smallest unit (decimal string). */ + public String amount; + + /** Bech32 recipient address. */ + public String payTo; + + /** Maximum payment validity in seconds; defaults to 300 (5 minutes). */ + public int maxTimeoutSeconds = CardanoConstants.DEFAULT_MAX_TIMEOUT_SECONDS; + + /** Mechanism-specific extras (e.g. {@code assetTransferMethod}). */ + public Map extra = new HashMap<>(); + + /** Default no-arg constructor (required by Jackson). */ + public CardanoPaymentRequirements() {} + + /** + * Build a Cardano payment requirements block. + * + * @param network x402 network identifier (e.g. {@code "cardano:preprod"}) + * @param asset asset unit ({@code "lovelace"} or {@code policyId.assetNameHex}) + * @param amount amount in smallest unit (decimal string) + * @param payTo bech32 recipient address + * @return a fully-populated requirements object with default timeout and + * {@code assetTransferMethod = "default"} + */ + public static CardanoPaymentRequirements forDefaultTransfer( + String network, String asset, String amount, String payTo) { + CardanoPaymentRequirements req = new CardanoPaymentRequirements(); + req.network = network; + req.asset = asset; + req.amount = amount; + req.payTo = payTo; + req.extra.put( + "assetTransferMethod", CardanoConstants.ASSET_TRANSFER_METHOD_DEFAULT); + return req; + } +} diff --git a/java/src/main/java/org/x402/cardano/CardanoSettleResponse.java b/java/src/main/java/org/x402/cardano/CardanoSettleResponse.java new file mode 100644 index 0000000000..8e6f389f62 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoSettleResponse.java @@ -0,0 +1,61 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Response DTO returned by the facilitator's {@code POST /settle} endpoint. + * + *

Wire format: + *

{@code
+ * {
+ *   "success": true,
+ *   "errorReason": null,
+ *   "errorMessage": null,
+ *   "payer": "addr_test1q...",
+ *   "transaction": "",
+ *   "network": "cardano:preprod",
+ *   "extensions": { "status": "confirmed" }
+ * }
+ * }
+ * + *

The {@code extensions.status} field is Cardano-specific and reports + * whether the transaction reached chain finality ({@code "confirmed"}) or + * was only accepted into the mempool ({@code "mempool"}). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CardanoSettleResponse { + + /** True when settlement succeeded. */ + public boolean success; + + /** Machine-readable error reason; null on success. */ + @JsonProperty("errorReason") + @JsonAlias({"error_reason"}) + public String errorReason; + + /** Human-readable error message; null on success. */ + @JsonProperty("errorMessage") + @JsonAlias({"error_message"}) + public String errorMessage; + + /** Payer bech32 address (best-effort). */ + public String payer; + + /** Cardano transaction hash returned by the chain on submit. */ + public String transaction; + + /** x402 Cardano network identifier (e.g. {@code "cardano:preprod"}). */ + public String network; + + /** Extension data (Cardano populates {@code extensions.status}). */ + public Map extensions; + + /** Optional final amount field (mirrors V2 spec). */ + public String amount; + + /** Default no-arg constructor (required by Jackson). */ + public CardanoSettleResponse() {} +} diff --git a/java/src/main/java/org/x402/cardano/CardanoVerifyResponse.java b/java/src/main/java/org/x402/cardano/CardanoVerifyResponse.java new file mode 100644 index 0000000000..8ce402eed5 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/CardanoVerifyResponse.java @@ -0,0 +1,47 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response DTO returned by the facilitator's {@code POST /verify} endpoint. + * + *

Wire format (camelCase, with snake_case aliases for cross-SDK compatibility): + *

{@code
+ * {
+ *   "isValid": false,
+ *   "invalidReason": "exact_cardano_facilitator_chain_lookup_failed",
+ *   "invalidMessage": "Blockfrost /tx/utxos returned 502",
+ *   "payer": "addr_test1q..."
+ * }
+ * }
+ * + *

{@code invalidReason} values come from {@link CardanoConstants}. + * {@code payer} is populated even on failure when the facilitator could + * resolve the input address from the chain. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CardanoVerifyResponse { + + /** True when the payment is valid; false otherwise. */ + @JsonProperty("isValid") + @JsonAlias({"is_valid"}) + public boolean isValid; + + /** Stable machine-readable reason code; null on success. */ + @JsonProperty("invalidReason") + @JsonAlias({"invalid_reason"}) + public String invalidReason; + + /** Free-form human-readable explanation; null on success. */ + @JsonProperty("invalidMessage") + @JsonAlias({"invalid_message"}) + public String invalidMessage; + + /** Payer's bech32 address (best-effort, may be null). */ + public String payer; + + /** Default no-arg constructor (required by Jackson). */ + public CardanoVerifyResponse() {} +} diff --git a/java/src/main/java/org/x402/cardano/ExactCardanoPayload.java b/java/src/main/java/org/x402/cardano/ExactCardanoPayload.java new file mode 100644 index 0000000000..5347473dc1 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/ExactCardanoPayload.java @@ -0,0 +1,69 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Map; + +/** + * Inner Cardano-specific payload carried inside a {@link CardanoPaymentPayload}. + * + *

Wire format (becomes {@code paymentPayload.payload}): + *

{@code
+ * { "transaction": "", "nonce": "#" }
+ * }
+ * + *

transaction is a base64-encoded, fully signed Cardano + * CBOR transaction. The facilitator decodes it with pycardano (Python) / + * cardano-serialization-lib (TypeScript) to verify the six rules. + * + *

nonce is a UTXO reference (txHashHex#index) that MUST + * also appear as a transaction input. The facilitator uses the nonce to + * enforce uniqueness and replay protection (rule 5 of the spec). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ExactCardanoPayload { + + /** Base64-encoded signed Cardano CBOR transaction. */ + public String transaction; + + /** UTXO reference ({@code txHashHex#index}) appearing as a tx input. */ + public String nonce; + + /** Default no-arg constructor (required by Jackson). */ + public ExactCardanoPayload() {} + + /** + * Convenience constructor. + * + * @param transaction base64-encoded signed CBOR transaction + * @param nonce UTXO reference (txHashHex#index) + */ + public ExactCardanoPayload(String transaction, String nonce) { + this.transaction = transaction; + this.nonce = nonce; + } + + /** + * Extract a typed Cardano payload from the generic map shape used by + * {@link CardanoPaymentPayload}. + * + * @param raw payload map (as deserialized from JSON) + * @return the typed payload + * @throws IllegalArgumentException when {@code transaction} or + * {@code nonce} are missing or empty + */ + public static ExactCardanoPayload fromMap(Map raw) { + if (raw == null) { + throw new IllegalArgumentException("Cardano payload is null"); + } + Object tx = raw.get("transaction"); + Object n = raw.get("nonce"); + if (!(tx instanceof String) || ((String) tx).isEmpty()) { + throw new IllegalArgumentException( + "Cardano payload is missing a transaction string"); + } + if (!(n instanceof String) || ((String) n).isEmpty()) { + throw new IllegalArgumentException("Cardano payload is missing a nonce string"); + } + return new ExactCardanoPayload((String) tx, (String) n); + } +} diff --git a/java/src/main/java/org/x402/cardano/PaymentResponseHeader.java b/java/src/main/java/org/x402/cardano/PaymentResponseHeader.java new file mode 100644 index 0000000000..d60e5d78c5 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/PaymentResponseHeader.java @@ -0,0 +1,80 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.x402.util.Json; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +/** + * Body of the {@code PAYMENT-RESPONSE} response header emitted on a + * successful settlement. + * + *

Wire format (base64-encoded JSON in the header value): + *

{@code
+ * {
+ *   "success":     true,
+ *   "transaction": "",
+ *   "network":     "cardano:preprod",
+ *   "extensions":  { "status": "confirmed" },
+ *   "errorReason": "Utxo not found in utxo set"
+ * }
+ * }
+ * + *

Clients (typically a wallet UI) decode this header to display tx hashes + * and confirmation state to the user. {@code errorReason} is only populated + * when {@code success} is {@code false}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PaymentResponseHeader { + + /** Always true when this header is sent (settle succeeded). */ + public boolean success = true; + + /** Cardano transaction hash. */ + public String transaction; + + /** Network identifier echoed from the requirements. */ + public String network; + + /** Extension data — for Cardano, {@code {"status": "confirmed" | "mempool"}}. */ + public Map extensions; + + /** Optional error reason when {@code success} is {@code false}. */ + public String errorReason; + + /** Default no-arg constructor (required by Jackson). */ + public PaymentResponseHeader() {} + + /** + * Build a header object from a {@link CardanoSettleResponse}. + * + * @param settle the facilitator settle response + * @return a header DTO suitable for {@link #encode()} + */ + public static PaymentResponseHeader fromSettle(CardanoSettleResponse settle) { + PaymentResponseHeader h = new PaymentResponseHeader(); + h.success = settle.success; + h.transaction = settle.transaction; + h.network = settle.network; + h.extensions = settle.extensions; + h.errorReason = settle.errorReason; + return h; + } + + /** + * Encode this header as a base64 string for HTTP transport. + * + * @return base64-encoded JSON + */ + public String encode() { + try { + String json = Json.MAPPER.writeValueAsString(this); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new IllegalStateException("Unable to encode PAYMENT-RESPONSE header", ex); + } + } +} diff --git a/java/src/main/java/org/x402/cardano/Price.java b/java/src/main/java/org/x402/cardano/Price.java new file mode 100644 index 0000000000..84b25653c3 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/Price.java @@ -0,0 +1,42 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * V2 price block: an asset unit + an atomic amount. + * + *

Wire format (under {@code accepts[].price} for outgoing 402 challenges + * and inside {@code accepts[]} flattened on requirements posted to a + * facilitator): + *

{@code
+ * { "amount": "5000000", "asset": "lovelace" }
+ * }
+ * + *

{@code amount} is always a decimal string in the asset's smallest unit + * (lovelace for ADA, the asset's native decimals for native tokens). + * {@code asset} is either the literal {@code "lovelace"} or a + * {@code policyId.assetNameHex} pair as defined in {@link CardanoConstants}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Price { + + /** Amount in the asset's smallest unit (e.g. lovelace for ADA). */ + public String amount; + + /** Asset unit ({@code "lovelace"} or {@code policyId.assetNameHex}). */ + public String asset; + + /** Default no-arg constructor (required by Jackson). */ + public Price() {} + + /** + * Convenience constructor. + * + * @param amount amount in smallest unit (decimal string) + * @param asset asset unit ({@code "lovelace"} or {@code policyId.assetNameHex}) + */ + public Price(String amount, String asset) { + this.amount = amount; + this.asset = asset; + } +} diff --git a/java/src/main/java/org/x402/cardano/ResourceInfo.java b/java/src/main/java/org/x402/cardano/ResourceInfo.java new file mode 100644 index 0000000000..18a5d434d3 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/ResourceInfo.java @@ -0,0 +1,45 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Describes the resource the client is paying for. + * + *

Returned in 402 challenges so wallets / aggregators can present the user + * with context about what they are about to pay for. + * + *

Wire format: + *

{@code
+ * { "url": "http://localhost:8003/premium",
+ *   "description": "Cardano premium endpoint (5 tADA)",
+ *   "mimeType": "application/json" }
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResourceInfo { + + /** Absolute URL of the protected resource. */ + public String url; + + /** Human-readable description; optional. */ + public String description; + + /** Expected MIME type of the response; optional. */ + public String mimeType; + + /** Default no-arg constructor (required by Jackson). */ + public ResourceInfo() {} + + /** + * Convenience constructor. + * + * @param url absolute URL of the resource + * @param description optional description + * @param mimeType optional MIME type + */ + public ResourceInfo(String url, String description, String mimeType) { + this.url = url; + this.description = description; + this.mimeType = mimeType; + } +} diff --git a/java/src/main/java/org/x402/cardano/RouteConfig.java b/java/src/main/java/org/x402/cardano/RouteConfig.java new file mode 100644 index 0000000000..ba921eccb7 --- /dev/null +++ b/java/src/main/java/org/x402/cardano/RouteConfig.java @@ -0,0 +1,59 @@ +package org.x402.cardano; + +import java.util.Objects; + +/** + * Per-route configuration consumed by {@link CardanoPaymentFilter}. + * + *

Bundles the {@link CardanoAccepts} block published in 402 challenges + * with optional resource metadata. The same configuration is used to build + * both the public 402 challenge (where {@code description} and + * {@code mimeType} go into the {@link ResourceInfo}) and the canonical + * {@link CardanoPaymentRequirements} forwarded to the facilitator. + */ +public final class RouteConfig { + + /** The Cardano accepts block (network, payTo, amount, asset, extra, ...). */ + public final CardanoAccepts accepts; + + /** Human-readable description published in the 402 challenge (optional). */ + public final String description; + + /** MIME type of the protected resource (optional). */ + public final String mimeType; + + /** + * Build a route configuration. + * + * @param accepts Cardano accepts block (must not be null) + * @param description optional description for the 402 challenge + * @param mimeType optional MIME type for the 402 challenge + */ + public RouteConfig(CardanoAccepts accepts, String description, String mimeType) { + this.accepts = Objects.requireNonNull(accepts, "accepts"); + this.description = description; + this.mimeType = mimeType; + } + + /** + * Convenience factory using {@link CardanoAccepts#forDefaultTransfer}. + * + * @param network x402 network identifier + * @param payTo bech32 recipient address + * @param amount amount in smallest unit (decimal string) + * @param asset asset unit + * @param description optional description + * @param mimeType optional MIME type + * @return a route configuration with default-method accepts + */ + public static RouteConfig forDefault( + String network, + String payTo, + String amount, + String asset, + String description, + String mimeType) { + CardanoAccepts a = CardanoAccepts.forDefaultTransfer(network, payTo, amount, asset); + return new RouteConfig(a, description, mimeType); + } +} diff --git a/java/src/test/java/org/x402/cardano/CardanoConstantsTest.java b/java/src/test/java/org/x402/cardano/CardanoConstantsTest.java new file mode 100644 index 0000000000..6e787f2316 --- /dev/null +++ b/java/src/test/java/org/x402/cardano/CardanoConstantsTest.java @@ -0,0 +1,58 @@ +package org.x402.cardano; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CardanoConstantsTest { + + @Test + void networkIdResolvesMainnetAndTestnets() { + assertEquals( + CardanoConstants.CARDANO_NETWORK_ID_MAINNET, + CardanoConstants.getNetworkId(CardanoConstants.CARDANO_MAINNET)); + assertEquals( + CardanoConstants.CARDANO_NETWORK_ID_TESTNET, + CardanoConstants.getNetworkId(CardanoConstants.CARDANO_PREPROD)); + assertEquals( + CardanoConstants.CARDANO_NETWORK_ID_TESTNET, + CardanoConstants.getNetworkId(CardanoConstants.CARDANO_PREVIEW)); + } + + @Test + void networkIdRejectsUnknown() { + assertThrows( + IllegalArgumentException.class, + () -> CardanoConstants.getNetworkId("eip155:8453")); + } + + @Test + void isCardanoNetworkRecognisesAllThree() { + assertTrue(CardanoConstants.isCardanoNetwork("cardano:mainnet")); + assertTrue(CardanoConstants.isCardanoNetwork("cardano:preprod")); + assertTrue(CardanoConstants.isCardanoNetwork("cardano:preview")); + assertFalse(CardanoConstants.isCardanoNetwork("solana:mainnet")); + assertFalse(CardanoConstants.isCardanoNetwork(null)); + } + + @Test + void assetRegexAcceptsLovelaceAndPolicyAssetPair() { + assertTrue("lovelace".matches(CardanoConstants.CARDANO_ASSET_REGEX)); + // 56 hex chars policy id + dot + asset name hex + String policy = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad"; + assertTrue((policy + ".0014df105553444d").matches(CardanoConstants.CARDANO_ASSET_REGEX)); + assertFalse("LOVELACE".matches(CardanoConstants.CARDANO_ASSET_REGEX)); + assertFalse("0xtoken".matches(CardanoConstants.CARDANO_ASSET_REGEX)); + } + + @Test + void utxoRefRegexShape() { + String txhash = "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b"; + assertTrue((txhash + "#3").matches(CardanoConstants.CARDANO_UTXO_REF_REGEX)); + assertFalse((txhash).matches(CardanoConstants.CARDANO_UTXO_REF_REGEX)); + assertFalse(("xy" + "#0").matches(CardanoConstants.CARDANO_UTXO_REF_REGEX)); + } +} diff --git a/java/src/test/java/org/x402/cardano/CardanoFacilitatorClientTest.java b/java/src/test/java/org/x402/cardano/CardanoFacilitatorClientTest.java new file mode 100644 index 0000000000..d0b1b3b4d0 --- /dev/null +++ b/java/src/test/java/org/x402/cardano/CardanoFacilitatorClientTest.java @@ -0,0 +1,154 @@ +package org.x402.cardano; + +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CardanoFacilitatorClientTest { + + static WireMockServer wm; + CardanoFacilitatorClient client; + + @BeforeAll + static void start() { + wm = new WireMockServer(0); + wm.start(); + } + + @AfterAll + static void stop() { + wm.stop(); + } + + @BeforeEach + void setUp() { + wm.resetAll(); + client = new CardanoFacilitatorClient("http://localhost:" + wm.port()); + } + + @Test + void trailingSlashIsTrimmed() { + CardanoFacilitatorClient withSlash = + new CardanoFacilitatorClient("http://localhost:" + wm.port() + "/"); + wm.stubFor( + get(urlEqualTo("/supported")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"kinds\":[]}"))); + assertDoesNotThrow(withSlash::supported); + } + + @Test + void verifyHappyPath() throws Exception { + wm.stubFor( + post(urlEqualTo("/verify")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + "{\"isValid\":true,\"payer\":\"addr_test1q...\"}"))); + + CardanoVerifyResponse vr = client.verify(samplePayload(), sampleRequirements()); + assertTrue(vr.isValid); + assertEquals("addr_test1q...", vr.payer); + + // Verify the envelope has x402Version=2 and the requirements are passed through. + wm.verify( + postRequestedFor(urlEqualTo("/verify")) + .withRequestBody(matchingJsonPath("$.x402Version", equalTo("2"))) + .withRequestBody( + matchingJsonPath( + "$.paymentRequirements.network", + equalTo("cardano:preprod")))); + } + + @Test + void verifyParsesSnakeCaseAliasesFromPython() throws Exception { + wm.stubFor( + post(urlEqualTo("/verify")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + "{\"is_valid\":false," + + "\"invalid_reason\":\"exact_cardano_facilitator_chain_lookup_failed\"," + + "\"invalid_message\":\"Blockfrost timed out\"}"))); + + CardanoVerifyResponse vr = client.verify(samplePayload(), sampleRequirements()); + assertFalse(vr.isValid); + assertEquals( + CardanoConstants.ERR_CHAIN_LOOKUP_FAILED, vr.invalidReason); + assertEquals("Blockfrost timed out", vr.invalidMessage); + } + + @Test + void settleHappyPath() throws Exception { + wm.stubFor( + post(urlEqualTo("/settle")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + "{\"success\":true," + + "\"transaction\":\"abcd1234\"," + + "\"network\":\"cardano:preprod\"," + + "\"payer\":\"addr_test1q...\"," + + "\"extensions\":{\"status\":\"confirmed\"}}"))); + CardanoSettleResponse sr = client.settle(samplePayload(), sampleRequirements()); + assertTrue(sr.success); + assertEquals("abcd1234", sr.transaction); + assertEquals("cardano:preprod", sr.network); + assertEquals("confirmed", sr.extensions.get("status")); + } + + @Test + void verifyThrowsOnNon200() { + wm.stubFor(post(urlEqualTo("/verify")).willReturn(aResponse().withStatus(503))); + assertThrows( + IOException.class, () -> client.verify(samplePayload(), sampleRequirements())); + } + + private static CardanoPaymentPayload samplePayload() { + CardanoPaymentPayload p = new CardanoPaymentPayload(); + Map inner = new HashMap<>(); + inner.put("transaction", "hKQA2QEC"); + inner.put("nonce", + "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b#3"); + p.payload = inner; + p.accepted = + CardanoPaymentRequirements.forDefaultTransfer( + CardanoConstants.CARDANO_PREPROD, + CardanoConstants.LOVELACE_ASSET, + "5000000", + "addr_test1qx"); + return p; + } + + private static CardanoPaymentRequirements sampleRequirements() { + return CardanoPaymentRequirements.forDefaultTransfer( + CardanoConstants.CARDANO_PREPROD, + CardanoConstants.LOVELACE_ASSET, + "5000000", + "addr_test1qx"); + } +} diff --git a/java/src/test/java/org/x402/cardano/CardanoPaymentFilterTest.java b/java/src/test/java/org/x402/cardano/CardanoPaymentFilterTest.java new file mode 100644 index 0000000000..8fc3155401 --- /dev/null +++ b/java/src/test/java/org/x402/cardano/CardanoPaymentFilterTest.java @@ -0,0 +1,255 @@ +package org.x402.cardano; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.x402.util.Json; + +import java.io.ByteArrayOutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CardanoPaymentFilterTest { + + @Mock HttpServletRequest req; + @Mock HttpServletResponse resp; + @Mock FilterChain chain; + @Mock CardanoFacilitator facilitator; + + private CardanoPaymentFilter filter; + private ByteArrayOutputStream body; + private final Map writtenHeaders = new HashMap<>(); + private final int[] writtenStatus = {200}; + + @BeforeEach + void init() throws Exception { + MockitoAnnotations.openMocks(this); + + body = new ByteArrayOutputStream(); + when(resp.getWriter()).thenReturn(new PrintWriter(body, true)); + + // Track headers + status set on the response. + when(resp.getStatus()).thenAnswer(inv -> writtenStatus[0]); + org.mockito.Mockito.doAnswer( + inv -> { + writtenStatus[0] = inv.getArgument(0); + return null; + }) + .when(resp).setStatus(org.mockito.ArgumentMatchers.anyInt()); + org.mockito.Mockito.doAnswer( + inv -> { + writtenHeaders.put(inv.getArgument(0), inv.getArgument(1)); + return null; + }) + .when(resp).setHeader(anyString(), anyString()); + when(resp.getHeader(anyString())).thenAnswer(inv -> writtenHeaders.get(inv.getArgument(0))); + + Map routes = + Map.of( + "/premium", + RouteConfig.forDefault( + CardanoConstants.CARDANO_PREPROD, + "addr_test1qxpaytotest", + "5000000", + CardanoConstants.LOVELACE_ASSET, + "Cardano premium endpoint", + "application/json")); + filter = new CardanoPaymentFilter(facilitator, routes); + } + + /* ---------- free path passes through unchanged ---------- */ + @Test + void freePathPassesThrough() throws Exception { + when(req.getRequestURI()).thenReturn("/healthz"); + filter.doFilter(req, resp, chain); + verify(chain).doFilter(req, resp); + verify(resp, never()).setStatus(org.mockito.ArgumentMatchers.anyInt()); + } + + /* ---------- OPTIONS preflight always passes through ---------- */ + @Test + void optionsPassesThroughEvenForProtectedRoute() throws Exception { + when(req.getMethod()).thenReturn("OPTIONS"); + when(req.getRequestURI()).thenReturn("/premium"); + filter.doFilter(req, resp, chain); + verify(chain).doFilter(req, resp); + } + + /* ---------- missing PAYMENT-SIGNATURE returns 402 with challenge ---------- */ + @Test + void missingHeaderRespondsWith402ChallengeAndExposesHeader() throws Exception { + when(req.getMethod()).thenReturn("GET"); + when(req.getRequestURI()).thenReturn("/premium"); + when(req.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost:8003/premium")); + when(req.getHeader(CardanoPaymentFilter.HDR_PAYMENT_SIGNATURE)).thenReturn(null); + + filter.doFilter(req, resp, chain); + + assertEquals(HttpServletResponse.SC_PAYMENT_REQUIRED, writtenStatus[0]); + verify(chain, never()).doFilter(any(), any()); + + // Body is the JSON challenge. + JsonNode challenge = Json.MAPPER.readTree(body.toString(StandardCharsets.UTF_8)); + assertEquals(2, challenge.get("x402Version").asInt()); + assertEquals(CardanoConstants.CARDANO_PREPROD, + challenge.get("accepts").get(0).get("network").asText()); + assertEquals(CardanoConstants.LOVELACE_ASSET, + challenge.get("accepts").get(0).get("asset").asText()); + assertEquals("5000000", + challenge.get("accepts").get(0).get("amount").asText()); + assertEquals("addr_test1qxpaytotest", + challenge.get("accepts").get(0).get("payTo").asText()); + + // PAYMENT-REQUIRED header is base64 of the same JSON. + String hdrB64 = writtenHeaders.get(CardanoPaymentFilter.HDR_PAYMENT_REQUIRED); + assertNotNull(hdrB64, "PAYMENT-REQUIRED header should be set"); + String decodedHdr = new String(Base64.getDecoder().decode(hdrB64), StandardCharsets.UTF_8); + assertEquals(challenge, Json.MAPPER.readTree(decodedHdr)); + + // CORS exposed list includes PAYMENT-REQUIRED. + String exposed = writtenHeaders.get("Access-Control-Expose-Headers"); + assertNotNull(exposed); + assertTrue(exposed.contains(CardanoPaymentFilter.HDR_PAYMENT_REQUIRED)); + } + + /* ---------- malformed PAYMENT-SIGNATURE returns 402 with invalid_payload ---------- */ + @Test + void malformedHeaderReturnsInvalidPayloadCode() throws Exception { + when(req.getMethod()).thenReturn("GET"); + when(req.getRequestURI()).thenReturn("/premium"); + when(req.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost:8003/premium")); + when(req.getHeader(CardanoPaymentFilter.HDR_PAYMENT_SIGNATURE)) + .thenReturn("not_valid_base64_$%^"); + + filter.doFilter(req, resp, chain); + + assertEquals(HttpServletResponse.SC_PAYMENT_REQUIRED, writtenStatus[0]); + JsonNode challenge = Json.MAPPER.readTree(body.toString(StandardCharsets.UTF_8)); + assertEquals(CardanoConstants.ERR_INVALID_PAYLOAD, challenge.get("error").asText()); + } + + /* ---------- facilitator says invalid → 402 with the facilitator's reason ---------- */ + @Test + void verifyInvalidReasonIsBubbledTo402() throws Exception { + when(req.getMethod()).thenReturn("GET"); + when(req.getRequestURI()).thenReturn("/premium"); + when(req.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost:8003/premium")); + + String header = makeValidPaymentSignatureHeader(); + when(req.getHeader(CardanoPaymentFilter.HDR_PAYMENT_SIGNATURE)).thenReturn(header); + + CardanoVerifyResponse vr = new CardanoVerifyResponse(); + vr.isValid = false; + vr.invalidReason = CardanoConstants.ERR_NONCE_NOT_ON_CHAIN; + when(facilitator.verify(any(), any())).thenReturn(vr); + + filter.doFilter(req, resp, chain); + + assertEquals(HttpServletResponse.SC_PAYMENT_REQUIRED, writtenStatus[0]); + JsonNode challenge = Json.MAPPER.readTree(body.toString(StandardCharsets.UTF_8)); + assertEquals(CardanoConstants.ERR_NONCE_NOT_ON_CHAIN, challenge.get("error").asText()); + verify(chain, never()).doFilter(any(), any()); + } + + /* ---------- happy path: verify ok → handler runs → settle ok → PAYMENT-RESPONSE ---------- */ + @Test + void happyPathSetsPaymentResponseHeader() throws Exception { + when(req.getMethod()).thenReturn("GET"); + when(req.getRequestURI()).thenReturn("/premium"); + when(req.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost:8003/premium")); + + String header = makeValidPaymentSignatureHeader(); + when(req.getHeader(CardanoPaymentFilter.HDR_PAYMENT_SIGNATURE)).thenReturn(header); + + CardanoVerifyResponse vr = new CardanoVerifyResponse(); + vr.isValid = true; + vr.payer = "addr_test1qpayer"; + when(facilitator.verify(any(), any())).thenReturn(vr); + + CardanoSettleResponse sr = new CardanoSettleResponse(); + sr.success = true; + sr.payer = "addr_test1qpayer"; + sr.transaction = "abc123"; + sr.network = CardanoConstants.CARDANO_PREPROD; + sr.extensions = Map.of("status", "confirmed"); + when(facilitator.settle(any(), any())).thenReturn(sr); + + filter.doFilter(req, resp, chain); + + verify(chain).doFilter(req, resp); + + String responseHeader = writtenHeaders.get(CardanoPaymentFilter.HDR_PAYMENT_RESPONSE); + assertNotNull(responseHeader); + String decoded = new String(Base64.getDecoder().decode(responseHeader), StandardCharsets.UTF_8); + JsonNode payload = Json.MAPPER.readTree(decoded); + assertTrue(payload.get("success").asBoolean()); + assertEquals("abc123", payload.get("transaction").asText()); + assertEquals(CardanoConstants.CARDANO_PREPROD, payload.get("network").asText()); + assertEquals("confirmed", payload.get("extensions").get("status").asText()); + } + + /* ---------- handler returned ≥400: settlement is skipped ---------- */ + @Test + void doesNotSettleWhenDownstreamHandlerErrors() throws Exception { + when(req.getMethod()).thenReturn("GET"); + when(req.getRequestURI()).thenReturn("/premium"); + when(req.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost:8003/premium")); + + String header = makeValidPaymentSignatureHeader(); + when(req.getHeader(CardanoPaymentFilter.HDR_PAYMENT_SIGNATURE)).thenReturn(header); + + CardanoVerifyResponse vr = new CardanoVerifyResponse(); + vr.isValid = true; + when(facilitator.verify(any(), any())).thenReturn(vr); + + // Simulate downstream returning 500. + org.mockito.Mockito.doAnswer( + inv -> { + writtenStatus[0] = 500; + return null; + }) + .when(chain).doFilter(any(), any()); + + filter.doFilter(req, resp, chain); + verify(facilitator, never()).settle(any(), any()); + } + + private static String makeValidPaymentSignatureHeader() { + CardanoPaymentPayload p = new CardanoPaymentPayload(); + Map inner = new HashMap<>(); + inner.put("transaction", "hKQA2QEC"); + inner.put( + "nonce", + "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b#3"); + p.payload = inner; + p.accepted = + CardanoPaymentRequirements.forDefaultTransfer( + CardanoConstants.CARDANO_PREPROD, + CardanoConstants.LOVELACE_ASSET, + "5000000", + "addr_test1qxpaytotest"); + return p.encodeHeader(); + } +} diff --git a/java/src/test/java/org/x402/cardano/CardanoPaymentPayloadTest.java b/java/src/test/java/org/x402/cardano/CardanoPaymentPayloadTest.java new file mode 100644 index 0000000000..a541c59bbd --- /dev/null +++ b/java/src/test/java/org/x402/cardano/CardanoPaymentPayloadTest.java @@ -0,0 +1,89 @@ +package org.x402.cardano; + +import org.junit.jupiter.api.Test; +import org.x402.util.Json; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CardanoPaymentPayloadTest { + + private static CardanoPaymentPayload sample() { + CardanoPaymentPayload p = new CardanoPaymentPayload(); + Map inner = new HashMap<>(); + inner.put("transaction", "hKQA2QECgoJYIHCLvww0YzOj"); + inner.put( + "nonce", + "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b#3"); + p.payload = inner; + p.accepted = + CardanoPaymentRequirements.forDefaultTransfer( + CardanoConstants.CARDANO_PREPROD, + CardanoConstants.LOVELACE_ASSET, + "5000000", + "addr_test1qx"); + return p; + } + + @Test + void roundTripsThroughBase64Header() { + CardanoPaymentPayload original = sample(); + String header = original.encodeHeader(); + CardanoPaymentPayload decoded = CardanoPaymentPayload.fromHeader(header); + assertEquals(2, decoded.x402Version); + assertEquals(original.payload.get("nonce"), decoded.payload.get("nonce")); + assertEquals(original.accepted.network, decoded.accepted.network); + assertEquals(original.accepted.amount, decoded.accepted.amount); + assertEquals(original.accepted.asset, decoded.accepted.asset); + } + + @Test + void rejectsEmptyHeader() { + assertThrows( + IllegalArgumentException.class, () -> CardanoPaymentPayload.fromHeader("")); + assertThrows( + IllegalArgumentException.class, () -> CardanoPaymentPayload.fromHeader(null)); + } + + @Test + void rejectsMalformedBase64() { + assertThrows( + IllegalArgumentException.class, + () -> CardanoPaymentPayload.fromHeader("not!base64!@@")); + } + + @Test + void getCardanoPayloadExtractsTypedFields() { + CardanoPaymentPayload p = sample(); + ExactCardanoPayload typed = p.getCardanoPayload(); + assertEquals("hKQA2QECgoJYIHCLvww0YzOj", typed.transaction); + assertEquals( + "708bbf0c346333a3503b216a4d093b693aa295d302c6af464ecd6a976159a45b#3", + typed.nonce); + } + + @Test + void getCardanoPayloadRejectsMissingFields() { + CardanoPaymentPayload p = new CardanoPaymentPayload(); + p.payload = new HashMap<>(); + assertThrows(IllegalArgumentException.class, p::getCardanoPayload); + } + + @Test + void wireFormatStaysCamelCaseAndOmitsNullFields() throws Exception { + CardanoPaymentRequirements r = + CardanoPaymentRequirements.forDefaultTransfer( + "cardano:preprod", "lovelace", "5000000", "addr_test1qx"); + String json = Json.MAPPER.writeValueAsString(r); + // Camel-cased on the wire; flat amount + asset (not nested). + assert json.contains("\"network\":\"cardano:preprod\""); + assert json.contains("\"amount\":\"5000000\""); + assert json.contains("\"asset\":\"lovelace\""); + assert json.contains("\"payTo\":\"addr_test1qx\""); + assert json.contains("\"maxTimeoutSeconds\":300"); + assert json.contains("\"assetTransferMethod\":\"default\""); + } +} diff --git a/python/x402/mechanisms/cardano/__init__.py b/python/x402/mechanisms/cardano/__init__.py new file mode 100644 index 0000000000..4b9cf9e9af --- /dev/null +++ b/python/x402/mechanisms/cardano/__init__.py @@ -0,0 +1,154 @@ +"""Cardano mechanism for the x402 payment protocol.""" + +from .constants import ( + ASSET_TRANSFER_METHOD_DEFAULT, + ASSET_TRANSFER_METHOD_MASUMI, + ASSET_TRANSFER_METHOD_SCRIPT, + CARDANO_ADDRESS_REGEX, + CARDANO_ASSET_REGEX, + CARDANO_MAINNET_CAIP2, + CARDANO_NETWORK_ID_MAINNET, + CARDANO_NETWORK_ID_TESTNET, + CARDANO_NETWORKS, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, + CARDANO_UTXO_REF_REGEX, + ERR_AMOUNT_INSUFFICIENT, + ERR_ASSET_MISMATCH, + ERR_CARDANO_SDK_MISSING, + ERR_CHAIN_LOOKUP_FAILED, + ERR_DUPLICATE_SETTLEMENT, + ERR_INVALID_PAYLOAD, + ERR_NETWORK_ID_MISMATCH, + ERR_NETWORK_MISMATCH, + ERR_NONCE_INVALID, + ERR_NONCE_NOT_IN_INPUTS, + ERR_NONCE_NOT_ON_CHAIN, + ERR_RECIPIENT_MISMATCH, + ERR_SCRIPT_ADDRESS_MISMATCH, + ERR_SETTLEMENT_FAILED, + ERR_SETTLEMENT_NOT_CONFIRMED, + ERR_TRANSACTION_DECODE_FAILED, + ERR_TRANSACTION_UNSIGNED, + ERR_TTL_EXPIRED, + ERR_UNSUPPORTED_SCHEME, + ERR_VALIDITY_NOT_YET_VALID, + LOVELACE_ASSET, + NETWORK_CONFIGS, + SCHEME_EXACT, + SETTLEMENT_TTL_SECONDS, + USDM_ASSET_NAME_HEX, + USDM_DEFAULT_DECIMALS, + USDM_MAINNET_ASSET, + USDM_MAINNET_POLICY_ID, + USDM_PREPROD_ASSET, + USDM_PREPROD_POLICY_ID, + CardanoNetworkConfig, + get_cardano_network_id, + get_default_usdm_asset, + is_cardano_network, +) +from .signer import ( + ClientCardanoSigner, + FacilitatorCardanoSigner, + FacilitatorCardanoSignerWithEvaluate, +) +from .types import ( + CardanoExtraDefault, + CardanoExtraMasumi, + CardanoExtraScript, + CardanoScriptDescriptor, + CardanoScriptParameter, + CardanoSubmissionResult, + CardanoUtxoOutput, + CardanoUtxoSnapshot, + DecodedCardanoTransaction, + ExactCardanoPayload, +) +from .utils import ( + CardanoSdkMissingError, + convert_to_token_amount, + decode_cardano_payload, + decode_cardano_transaction, + encode_cardano_payload, + output_satisfies, + parse_asset_unit, + parse_money_to_decimal, + parse_utxo_ref, +) + +__all__ = [ + # Constants + "SCHEME_EXACT", + "CARDANO_MAINNET_CAIP2", + "CARDANO_PREPROD_CAIP2", + "CARDANO_PREVIEW_CAIP2", + "CARDANO_NETWORKS", + "CARDANO_NETWORK_ID_MAINNET", + "CARDANO_NETWORK_ID_TESTNET", + "CARDANO_ASSET_REGEX", + "CARDANO_ADDRESS_REGEX", + "CARDANO_UTXO_REF_REGEX", + "USDM_MAINNET_POLICY_ID", + "USDM_PREPROD_POLICY_ID", + "USDM_ASSET_NAME_HEX", + "USDM_MAINNET_ASSET", + "USDM_PREPROD_ASSET", + "USDM_DEFAULT_DECIMALS", + "LOVELACE_ASSET", + "ASSET_TRANSFER_METHOD_DEFAULT", + "ASSET_TRANSFER_METHOD_MASUMI", + "ASSET_TRANSFER_METHOD_SCRIPT", + "NETWORK_CONFIGS", + "CardanoNetworkConfig", + "SETTLEMENT_TTL_SECONDS", + # Errors + "ERR_UNSUPPORTED_SCHEME", + "ERR_NETWORK_MISMATCH", + "ERR_INVALID_PAYLOAD", + "ERR_TRANSACTION_DECODE_FAILED", + "ERR_TRANSACTION_UNSIGNED", + "ERR_NETWORK_ID_MISMATCH", + "ERR_RECIPIENT_MISMATCH", + "ERR_ASSET_MISMATCH", + "ERR_AMOUNT_INSUFFICIENT", + "ERR_NONCE_INVALID", + "ERR_NONCE_NOT_IN_INPUTS", + "ERR_NONCE_NOT_ON_CHAIN", + "ERR_TTL_EXPIRED", + "ERR_VALIDITY_NOT_YET_VALID", + "ERR_CHAIN_LOOKUP_FAILED", + "ERR_SETTLEMENT_FAILED", + "ERR_SETTLEMENT_NOT_CONFIRMED", + "ERR_DUPLICATE_SETTLEMENT", + "ERR_SCRIPT_ADDRESS_MISMATCH", + "ERR_CARDANO_SDK_MISSING", + # Helpers + "get_cardano_network_id", + "is_cardano_network", + "get_default_usdm_asset", + "parse_asset_unit", + "parse_utxo_ref", + "encode_cardano_payload", + "decode_cardano_payload", + "output_satisfies", + "parse_money_to_decimal", + "convert_to_token_amount", + "decode_cardano_transaction", + "CardanoSdkMissingError", + # Types + "ExactCardanoPayload", + "CardanoUtxoOutput", + "CardanoUtxoSnapshot", + "CardanoSubmissionResult", + "DecodedCardanoTransaction", + "CardanoExtraDefault", + "CardanoExtraMasumi", + "CardanoExtraScript", + "CardanoScriptDescriptor", + "CardanoScriptParameter", + # Signer protocols + "ClientCardanoSigner", + "FacilitatorCardanoSigner", + "FacilitatorCardanoSignerWithEvaluate", +] diff --git a/python/x402/mechanisms/cardano/constants.py b/python/x402/mechanisms/cardano/constants.py new file mode 100644 index 0000000000..093756bba3 --- /dev/null +++ b/python/x402/mechanisms/cardano/constants.py @@ -0,0 +1,144 @@ +"""Cardano mechanism constants - network configs, USDM defaults, error codes.""" + +from typing import TypedDict + +# Scheme identifier (matches other mechanisms). +SCHEME_EXACT = "exact" + +# x402 network identifiers for Cardano (per spec — *not* canonical CAIP-2). +CARDANO_MAINNET_CAIP2 = "cardano:mainnet" +CARDANO_PREPROD_CAIP2 = "cardano:preprod" +CARDANO_PREVIEW_CAIP2 = "cardano:preview" + +CARDANO_NETWORKS: list[str] = [ + CARDANO_MAINNET_CAIP2, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, +] + +# Cardano network IDs encoded in the transaction body. Mainnet=1, every testnet=0. +CARDANO_NETWORK_ID_MAINNET = 1 +CARDANO_NETWORK_ID_TESTNET = 0 + +# Default USDM token defaults (per the spec example). +USDM_MAINNET_POLICY_ID = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad" +USDM_PREPROD_POLICY_ID = "16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde" +USDM_ASSET_NAME_HEX = "0014df105553444d" # CIP-68 (333) prefix + 'USDM' +USDM_MAINNET_ASSET = f"{USDM_MAINNET_POLICY_ID}.{USDM_ASSET_NAME_HEX}" +USDM_PREPROD_ASSET = f"{USDM_PREPROD_POLICY_ID}.{USDM_ASSET_NAME_HEX}" +USDM_DEFAULT_DECIMALS = 6 + +# Asset identifier for native ADA. The Cardano facilitator special-cases this +# value: lovelace lives in an output's `coin` field, not in its multi-asset +# map, so the verifier compares against `output.coin` when the asset string +# is exactly `"lovelace"`. +LOVELACE_ASSET = "lovelace" + +# Validation regexes. The asset regex accepts either the literal "lovelace" +# (native ADA) or `policyId.assetNameHex` for native tokens. +CARDANO_ASSET_REGEX = r"^(lovelace|[0-9a-fA-F]{56}\.[0-9a-fA-F]{0,64})$" +CARDANO_ADDRESS_REGEX = r"^(addr1|addr_test1)[0-9a-z]+$" +CARDANO_UTXO_REF_REGEX = r"^[0-9a-fA-F]{64}#\d+$" + +# Asset transfer method markers. +ASSET_TRANSFER_METHOD_DEFAULT = "default" +ASSET_TRANSFER_METHOD_MASUMI = "masumi" +ASSET_TRANSFER_METHOD_SCRIPT = "script" + +# Error codes — mirror SVM/Aptos style for log filtering. +ERR_UNSUPPORTED_SCHEME = "unsupported_scheme" +ERR_INVALID_PAYLOAD = "invalid_exact_cardano_payload" +ERR_NETWORK_MISMATCH = "network_mismatch" +ERR_TRANSACTION_DECODE_FAILED = "invalid_exact_cardano_payload_transaction_decode_failed" +ERR_NETWORK_ID_MISMATCH = "invalid_exact_cardano_payload_network_id_mismatch" +ERR_RECIPIENT_MISMATCH = "invalid_exact_cardano_payload_recipient_mismatch" +ERR_ASSET_MISMATCH = "invalid_exact_cardano_payload_asset_mismatch" +ERR_AMOUNT_INSUFFICIENT = "invalid_exact_cardano_payload_amount_insufficient" +ERR_NONCE_INVALID = "invalid_exact_cardano_payload_nonce_invalid" +ERR_NONCE_NOT_IN_INPUTS = "invalid_exact_cardano_payload_nonce_not_in_inputs" +ERR_NONCE_NOT_ON_CHAIN = "invalid_exact_cardano_payload_nonce_not_on_chain" +ERR_TTL_EXPIRED = "invalid_exact_cardano_payload_ttl_expired" +ERR_VALIDITY_NOT_YET_VALID = "invalid_exact_cardano_payload_not_yet_valid" +ERR_CHAIN_LOOKUP_FAILED = "exact_cardano_facilitator_chain_lookup_failed" +ERR_SETTLEMENT_FAILED = "exact_cardano_settlement_failed" +ERR_SETTLEMENT_NOT_CONFIRMED = "exact_cardano_settlement_not_confirmed" +ERR_DUPLICATE_SETTLEMENT = "duplicate_settlement" +ERR_SCRIPT_ADDRESS_MISMATCH = "invalid_exact_cardano_payload_script_address_mismatch" +ERR_CARDANO_SDK_MISSING = "exact_cardano_sdk_missing" +ERR_TRANSACTION_UNSIGNED = "invalid_exact_cardano_payload_unsigned" + +# Duplicate-settlement cache lifetime (seconds). 2 minutes covers typical TTLs. +SETTLEMENT_TTL_SECONDS = 120.0 + + +class CardanoNetworkConfig(TypedDict): + """Network configuration entry for Cardano.""" + + network_id: int + default_asset: str | None + + +NETWORK_CONFIGS: dict[str, CardanoNetworkConfig] = { + CARDANO_MAINNET_CAIP2: { + "network_id": CARDANO_NETWORK_ID_MAINNET, + "default_asset": USDM_MAINNET_ASSET, + }, + CARDANO_PREPROD_CAIP2: { + "network_id": CARDANO_NETWORK_ID_TESTNET, + "default_asset": USDM_PREPROD_ASSET, + }, + CARDANO_PREVIEW_CAIP2: { + "network_id": CARDANO_NETWORK_ID_TESTNET, + "default_asset": None, + }, +} + + +def get_cardano_network_id(network: str) -> int: + """Return the Cardano network id (1 mainnet, 0 testnet) for the supplied + x402 network identifier. + + Args: + network: The x402 network identifier (e.g. "cardano:mainnet"). + + Returns: + The Cardano network id. + + Raises: + ValueError: When the network is not a supported Cardano network. + """ + config = NETWORK_CONFIGS.get(network) + if config is None: + raise ValueError(f"Unsupported Cardano network: {network}") + return config["network_id"] + + +def is_cardano_network(network: str) -> bool: + """Return True when the supplied identifier names a supported Cardano + network. + + Args: + network: The network identifier to check. + + Returns: + True if the network is a supported Cardano network. + """ + return network in NETWORK_CONFIGS + + +def get_default_usdm_asset(network: str) -> str: + """Return the default USDM asset unit for the requested network. + + Args: + network: The Cardano network identifier. + + Returns: + The default USDM asset unit string. + + Raises: + ValueError: When no default USDM is configured (Preview today). + """ + config = NETWORK_CONFIGS.get(network) + if config is None or not config["default_asset"]: + raise ValueError(f"No default USDM asset configured for network: {network}") + return config["default_asset"] diff --git a/python/x402/mechanisms/cardano/exact/__init__.py b/python/x402/mechanisms/cardano/exact/__init__.py new file mode 100644 index 0000000000..59f5d9865f --- /dev/null +++ b/python/x402/mechanisms/cardano/exact/__init__.py @@ -0,0 +1,23 @@ +"""Exact Cardano payment scheme for x402.""" + +from .client import ExactCardanoScheme as ExactCardanoClientScheme +from .facilitator import ExactCardanoScheme as ExactCardanoFacilitatorScheme +from .register import ( + register_exact_cardano_client, + register_exact_cardano_facilitator, + register_exact_cardano_server, +) +from .server import ExactCardanoScheme as ExactCardanoServerScheme + +# Default re-export points at the client scheme to mirror SVM/EVM conventions. +ExactCardanoScheme = ExactCardanoClientScheme + +__all__ = [ + "ExactCardanoScheme", + "ExactCardanoClientScheme", + "ExactCardanoServerScheme", + "ExactCardanoFacilitatorScheme", + "register_exact_cardano_client", + "register_exact_cardano_server", + "register_exact_cardano_facilitator", +] diff --git a/python/x402/mechanisms/cardano/exact/client.py b/python/x402/mechanisms/cardano/exact/client.py new file mode 100644 index 0000000000..71818239e5 --- /dev/null +++ b/python/x402/mechanisms/cardano/exact/client.py @@ -0,0 +1,89 @@ +"""Cardano client implementation for the Exact payment scheme (V2).""" + +from __future__ import annotations + +import re +from typing import Any + +from ....schemas import PaymentRequirements +from ..constants import ( + CARDANO_ADDRESS_REGEX, + CARDANO_ASSET_REGEX, + CARDANO_UTXO_REF_REGEX, + SCHEME_EXACT, + is_cardano_network, +) +from ..signer import ClientCardanoSigner +from ..types import ExactCardanoPayload + +_ADDRESS_RE = re.compile(CARDANO_ADDRESS_REGEX) +_ASSET_RE = re.compile(CARDANO_ASSET_REGEX) +_UTXO_RE = re.compile(CARDANO_UTXO_REF_REGEX) +_AMOUNT_RE = re.compile(r"^[0-9]+$") + + +class ExactCardanoScheme: + """Cardano client implementation for the Exact payment scheme (V2). + + Implements `SchemeNetworkClient`. Returns the inner payload dict, which + `x402Client` wraps into a full PaymentPayload. + + Attributes: + scheme: The scheme identifier ("exact"). + """ + + scheme = SCHEME_EXACT + + def __init__(self, signer: ClientCardanoSigner) -> None: + """Create the Cardano client scheme. + + Args: + signer: A signer that produces base64-encoded signed transactions + and the UTXO reference used as nonce. + """ + self._signer = signer + + def create_payment_payload(self, requirements: PaymentRequirements) -> dict[str, Any]: + """Build a Cardano payment payload via the configured signer. + + Args: + requirements: The x402 payment requirements to fulfill. + + Returns: + The inner payload dict (`{transaction, nonce}`). + + Raises: + ValueError: If requirements are invalid for Cardano. + RuntimeError: If the signer returns an invalid response. + """ + network = str(requirements.network) + if not is_cardano_network(network): + raise ValueError(f"Unsupported Cardano network: {network}") + if not requirements.pay_to: + raise ValueError("Pay-to address is required") + if not _ADDRESS_RE.match(requirements.pay_to): + raise ValueError(f"Invalid Cardano pay-to address: {requirements.pay_to}") + if not requirements.asset: + raise ValueError("Asset is required") + if not _ASSET_RE.match(requirements.asset): + raise ValueError(f"Invalid Cardano asset unit: {requirements.asset}") + if not requirements.amount: + raise ValueError("Amount is required") + if not _AMOUNT_RE.match(requirements.amount): + raise ValueError(f"Amount must be a non-negative integer, got: {requirements.amount}") + + result = self._signer.sign_payment_transaction( + network=network, + pay_to=requirements.pay_to, + asset=requirements.asset, + amount=requirements.amount, + max_timeout_seconds=requirements.max_timeout_seconds, + extra=dict(requirements.extra) if requirements.extra else None, + ) + transaction = result.get("transaction") if isinstance(result, dict) else None + nonce = result.get("nonce") if isinstance(result, dict) else None + if not isinstance(transaction, str) or not transaction: + raise RuntimeError("Cardano signer returned an empty transaction") + if not isinstance(nonce, str) or not _UTXO_RE.match(nonce): + raise RuntimeError(f"Cardano signer returned an invalid nonce: {nonce}") + return ExactCardanoPayload(transaction=transaction, nonce=nonce).to_dict() diff --git a/python/x402/mechanisms/cardano/exact/facilitator.py b/python/x402/mechanisms/cardano/exact/facilitator.py new file mode 100644 index 0000000000..b870edbdd4 --- /dev/null +++ b/python/x402/mechanisms/cardano/exact/facilitator.py @@ -0,0 +1,452 @@ +"""Cardano facilitator implementation for the Exact payment scheme (V2).""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +from ....schemas import ( + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +) +from ..constants import ( + ASSET_TRANSFER_METHOD_DEFAULT, + ASSET_TRANSFER_METHOD_MASUMI, + ASSET_TRANSFER_METHOD_SCRIPT, + ERR_AMOUNT_INSUFFICIENT, + ERR_ASSET_MISMATCH, + ERR_CHAIN_LOOKUP_FAILED, + ERR_DUPLICATE_SETTLEMENT, + ERR_INVALID_PAYLOAD, + ERR_NETWORK_ID_MISMATCH, + ERR_NETWORK_MISMATCH, + ERR_NONCE_INVALID, + ERR_NONCE_NOT_IN_INPUTS, + ERR_NONCE_NOT_ON_CHAIN, + ERR_RECIPIENT_MISMATCH, + ERR_SCRIPT_ADDRESS_MISMATCH, + ERR_SETTLEMENT_FAILED, + ERR_SETTLEMENT_NOT_CONFIRMED, + ERR_TRANSACTION_DECODE_FAILED, + ERR_TRANSACTION_UNSIGNED, + ERR_TTL_EXPIRED, + ERR_UNSUPPORTED_SCHEME, + ERR_VALIDITY_NOT_YET_VALID, + SCHEME_EXACT, + SETTLEMENT_TTL_SECONDS, + get_cardano_network_id, + is_cardano_network, +) +from ..signer import FacilitatorCardanoSigner +from ..types import ( + CardanoUtxoSnapshot, + DecodedCardanoTransaction, + ExactCardanoPayload, +) +from ..utils import ( + CardanoSdkMissingError, + decode_cardano_payload, + decode_cardano_transaction, + parse_utxo_ref, +) + + +class ExactCardanoScheme: + """Cardano facilitator implementation for the Exact payment scheme (V2). + + Verifies and settles Cardano native-token payments per the spec's six + facilitator verification rules. + + Attributes: + scheme: Scheme identifier ("exact"). + caip_family: CAIP family pattern ("cardano:*"). + """ + + scheme = SCHEME_EXACT + caip_family = "cardano:*" + + def __init__( + self, + signer: FacilitatorCardanoSigner, + *, + accept_mempool: bool = False, + duplicate_cache_ttl_seconds: float = SETTLEMENT_TTL_SECONDS, + ) -> None: + """Create the facilitator scheme. + + Args: + signer: Chain query / submission implementation. + accept_mempool: When True, settlement is considered successful + even if the signer reports `mempool` only. Defaults to False. + duplicate_cache_ttl_seconds: Lifetime of the duplicate-settlement + cache. Defaults to two minutes. + """ + self._signer = signer + self._accept_mempool = accept_mempool + self._duplicate_cache_ttl = duplicate_cache_ttl_seconds + self._settlement_cache: dict[str, float] = {} + # Lock guards _try_claim/_release_claim against concurrent sync calls. + self._cache_lock = threading.Lock() + + # ------------------------------------------------------------------ # + # Mechanism protocol + # ------------------------------------------------------------------ # + + def get_extra(self, network: Network) -> dict[str, Any] | None: + """Return mechanism-specific extra metadata for `/supported`. + + The default Cardano scheme has no required metadata; subclasses can + override. + + Args: + network: The Cardano network identifier (unused). + + Returns: + Always None. + """ + _ = network + return None + + def get_signers(self, network: Network) -> list[str]: + """Return facilitator addresses for the supplied network. + + Args: + network: Cardano network identifier (unused). + + Returns: + List of bech32 facilitator addresses. + """ + _ = network + return list(self._signer.get_addresses()) + + def verify( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + context: Any | None = None, + ) -> VerifyResponse: + """Verify a Cardano payment per the spec's six rules. + + Args: + payload: Payment payload from the client. + requirements: Payment requirements being fulfilled. + context: Optional facilitator context (unused). + + Returns: + A VerifyResponse describing success or failure. + """ + _ = context + if payload.x402_version != 2: + return VerifyResponse( + is_valid=False, + invalid_reason=f"{ERR_INVALID_PAYLOAD}_unsupported_version", + payer="", + ) + + if payload.accepted.scheme != SCHEME_EXACT or requirements.scheme != SCHEME_EXACT: + return VerifyResponse(is_valid=False, invalid_reason=ERR_UNSUPPORTED_SCHEME, payer="") + + accepted_network = str(payload.accepted.network) + required_network = str(requirements.network) + if accepted_network != required_network: + return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer="") + if not is_cardano_network(required_network): + return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer="") + + try: + cardano_payload = decode_cardano_payload(payload.payload) + except Exception: + return VerifyResponse(is_valid=False, invalid_reason=ERR_INVALID_PAYLOAD, payer="") + + try: + tx_hash, index = parse_utxo_ref(cardano_payload.nonce) + except ValueError: + return VerifyResponse(is_valid=False, invalid_reason=ERR_NONCE_INVALID, payer="") + nonce_lower = f"{tx_hash}#{index}" + + try: + decoded: DecodedCardanoTransaction = decode_cardano_transaction( + cardano_payload.transaction + ) + except CardanoSdkMissingError as exc: + # Missing optional SDK is a facilitator misconfiguration, not a + # client error. + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_CHAIN_LOOKUP_FAILED, + invalid_message=str(exc), + payer="", + ) + except Exception as exc: + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_TRANSACTION_DECODE_FAILED, + invalid_message=str(exc), + payer="", + ) + + # Rule 1: When the transaction body declares a network_id it MUST match + # the declared network. Absence is permitted: the field is optional in + # the Cardano CBOR spec and many wallets omit it. Network correctness is + # still enforced by Rule 3 (payTo address check): Cardano addresses are + # network-tagged (addr_test1... vs addr1...), so a testnet address + # cannot be submitted on mainnet and vice versa. + expected_network_id = get_cardano_network_id(required_network) + if decoded.network_id is not None and decoded.network_id != expected_network_id: + return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_ID_MISMATCH, payer="") + + # SECURITY: refuse unsigned transactions in verify() so /verify cannot + # return a false-positive that would let callers grant access on an + # unpaid request. Submission would also fail, but the spec's verify() + # is supposed to detect this up front. + if decoded.vkey_witness_count == 0 and decoded.script_witness_count == 0: + return VerifyResponse(is_valid=False, invalid_reason=ERR_TRANSACTION_UNSIGNED, payer="") + + # Rule 6 (and lower-bound): TTL must not be in the past, AND any + # validity-start (lower bound) must already have arrived. Both checks + # need the current slot, so do a single lookup when either bound is + # present. + if decoded.ttl_slot is not None or decoded.validity_start_slot is not None: + try: + current_slot = int(self._signer.get_current_slot(required_network)) + except Exception as exc: + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_CHAIN_LOOKUP_FAILED, + invalid_message=str(exc), + payer="", + ) + if decoded.ttl_slot is not None and decoded.ttl_slot <= current_slot: + return VerifyResponse(is_valid=False, invalid_reason=ERR_TTL_EXPIRED, payer="") + if ( + decoded.validity_start_slot is not None + and decoded.validity_start_slot > current_slot + ): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_VALIDITY_NOT_YET_VALID, payer="" + ) + + # Rule 5 (input check): nonce UTXO must appear as an input. + input_set = {i.lower() for i in decoded.inputs} + if nonce_lower not in input_set: + return VerifyResponse(is_valid=False, invalid_reason=ERR_NONCE_NOT_IN_INPUTS, payer="") + + # Rule 5 (chain check): nonce UTXO must currently be unspent. + try: + snapshot: CardanoUtxoSnapshot = self._signer.get_utxo(nonce_lower, required_network) + except Exception as exc: + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_CHAIN_LOOKUP_FAILED, + invalid_message=str(exc), + payer="", + ) + if not snapshot.exists: + return VerifyResponse(is_valid=False, invalid_reason=ERR_NONCE_NOT_ON_CHAIN, payer="") + + payer = snapshot.address or "" + + # Rules 2, 3, 4: at least one output must pay enough of the right + # asset to the right address. Lovelace is special-cased: native ADA + # lives in the output's `coin` field rather than the multi-asset map. + required_amount = int(requirements.amount) + asset_key = requirements.asset.lower() + is_lovelace = asset_key == "lovelace" + recipient_seen = False + asset_seen_for_recipient = False + + for output in decoded.outputs: + if output.address != requirements.pay_to: + continue + recipient_seen = True + if is_lovelace: + available: int | None = output.coin + else: + available = output.assets.get(asset_key) + if available is None: + continue + asset_seen_for_recipient = True + if available >= required_amount: + # SECURITY: Read assetTransferMethod from the canonical + # server-supplied requirements, NOT from payload.accepted.extra + # (which is client-echoed and could lie about the method to + # bypass script-mode checks). + method_check = self._run_method_specific_checks( + requirements.extra, requirements.pay_to, payer + ) + if not method_check[0]: + return VerifyResponse( + is_valid=False, invalid_reason=method_check[1], payer=payer + ) + # Optional cryptographic authorization check via a Cardano + # node dry-run. Skipped when the signer does not implement + # `evaluate_transaction`; in that case verify() is best-effort + # and settle() will still surface signature errors at submit. + evaluate = getattr(self._signer, "evaluate_transaction", None) + if callable(evaluate): + try: + evaluate(cardano_payload.transaction, required_network) + except Exception as exc: + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_CHAIN_LOOKUP_FAILED, + invalid_message=str(exc), + payer=payer, + ) + return VerifyResponse(is_valid=True, payer=payer) + + if not recipient_seen: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_RECIPIENT_MISMATCH, payer=payer + ) + if not asset_seen_for_recipient: + return VerifyResponse(is_valid=False, invalid_reason=ERR_ASSET_MISMATCH, payer=payer) + return VerifyResponse(is_valid=False, invalid_reason=ERR_AMOUNT_INSUFFICIENT, payer=payer) + + def settle( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + context: Any | None = None, + ) -> SettleResponse: + """Settle a Cardano payment by re-verifying and submitting. + + Args: + payload: The payment payload to settle. + requirements: Payment requirements. + context: Optional facilitator context (unused). + + Returns: + A SettleResponse describing success or failure. + """ + verify_result = self.verify(payload, requirements, context) + if not verify_result.is_valid: + return SettleResponse( + success=False, + error_reason=verify_result.invalid_reason or "verification_failed", + transaction="", + network=str(payload.accepted.network), + ) + + cardano_payload = ExactCardanoPayload.from_dict(payload.payload) + cache_key = cardano_payload.transaction + # Atomically claim the cache key BEFORE submission so concurrent + # settle() calls cannot race past the duplicate check. + if not self._try_claim(cache_key): + return SettleResponse( + success=False, + error_reason=ERR_DUPLICATE_SETTLEMENT, + transaction="", + network=str(payload.accepted.network), + ) + + try: + submission = self._signer.submit_transaction( + cardano_payload.transaction, str(requirements.network) + ) + except Exception: + # Submission threw — release the claim so the caller can retry + # with a corrected payload. + self._release_claim(cache_key) + return SettleResponse( + success=False, + error_reason=ERR_SETTLEMENT_FAILED, + transaction="", + network=str(payload.accepted.network), + ) + + if submission.status != "confirmed" and not self._accept_mempool: + # Spec: granting access on mempool inclusion is strongly + # discouraged because Cardano has probabilistic finality. Keep the + # claim in place even on rejection so retries cannot rebroadcast + # the same transaction repeatedly. + return SettleResponse( + success=False, + error_reason=ERR_SETTLEMENT_NOT_CONFIRMED, + transaction=submission.tx_hash, + network=str(payload.accepted.network), + extensions={"status": submission.status}, + ) + + return SettleResponse( + success=True, + transaction=submission.tx_hash, + network=str(payload.accepted.network), + extensions={"status": submission.status}, + ) + + # ------------------------------------------------------------------ # + # Hooks + # ------------------------------------------------------------------ # + + def _run_method_specific_checks( + self, + extra: dict[str, Any] | None, + pay_to: str, + payer: str, + ) -> tuple[bool, str]: + """Verify the assetTransferMethod-specific extras. + + Args: + extra: The accepted requirements' extra block. + pay_to: The recipient address declared in the payment requirements. + payer: The payer address (passed through for context). + + Returns: + Tuple `(ok, reason_or_empty)`. When `ok` is False, `reason` is the + invalid_reason to return to callers. + """ + _ = pay_to, payer + method = (extra or {}).get("assetTransferMethod") or ASSET_TRANSFER_METHOD_DEFAULT + if method in (ASSET_TRANSFER_METHOD_DEFAULT, ASSET_TRANSFER_METHOD_MASUMI): + return True, "" + if method == ASSET_TRANSFER_METHOD_SCRIPT: + extra_d = extra or {} + if "scriptHash" not in extra_d and "script" not in extra_d: + return False, ERR_SCRIPT_ADDRESS_MISMATCH + # SECURITY: per the spec, the facilitator must reconstruct the + # script address from script + parameters and verify it equals + # `pay_to`. The base class cannot perform this without an SDK, + # so we reject by default and require integrators to override. + return False, ERR_SCRIPT_ADDRESS_MISMATCH + return False, ERR_UNSUPPORTED_SCHEME + + def _try_claim(self, key: str) -> bool: + """Atomically claim a cache key for an in-flight settlement. + + Held under a lock so concurrent threaded settle() calls cannot both + pass the duplicate check before the first submission returns. + + Args: + key: Cache key (typically the base64 transaction). + + Returns: + True when the claim was acquired; False when the key was already + claimed within the duplicate-settlement window. + """ + with self._cache_lock: + seen_at = self._settlement_cache.get(key) + now = time.time() + if seen_at is not None and now - seen_at <= self._duplicate_cache_ttl: + return False + self._settlement_cache[key] = now + if len(self._settlement_cache) > 1024: + cutoff = now - self._duplicate_cache_ttl + self._settlement_cache = { + k: t for k, t in self._settlement_cache.items() if t >= cutoff + } + return True + + def _release_claim(self, key: str) -> None: + """Release a previously-claimed cache key so retries can attempt + settlement again. Called when submission throws a transient error. + + Args: + key: Cache key (typically the base64 transaction). + """ + with self._cache_lock: + self._settlement_cache.pop(key, None) diff --git a/python/x402/mechanisms/cardano/exact/register.py b/python/x402/mechanisms/cardano/exact/register.py new file mode 100644 index 0000000000..5b34596a07 --- /dev/null +++ b/python/x402/mechanisms/cardano/exact/register.py @@ -0,0 +1,116 @@ +"""Registration helpers for the Cardano exact payment scheme.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from ..constants import CARDANO_NETWORKS + +if TYPE_CHECKING: + from x402 import ( + x402Client, + x402ClientSync, + x402Facilitator, + x402FacilitatorSync, + x402ResourceServer, + x402ResourceServerSync, + ) + + from ..signer import ClientCardanoSigner, FacilitatorCardanoSigner + +ClientT = TypeVar("ClientT", "x402Client", "x402ClientSync") +ServerT = TypeVar("ServerT", "x402ResourceServer", "x402ResourceServerSync") +FacilitatorT = TypeVar("FacilitatorT", "x402Facilitator", "x402FacilitatorSync") + + +def register_exact_cardano_client( + client: ClientT, + signer: ClientCardanoSigner, + networks: str | list[str] | None = None, + policies: list | None = None, +) -> ClientT: + """Register the Cardano client scheme on an x402 client. + + Args: + client: The x402Client instance. + signer: The Cardano client signer. + networks: Optional specific network(s); defaults to every supported + Cardano network registered individually (avoids the wildcard + footgun where unsupported `cardano:*` networks would dispatch + to this scheme). + policies: Optional payment policies. + + Returns: + The client for chaining. + """ + from .client import ExactCardanoScheme as ClientScheme + + scheme = ClientScheme(signer) + networks_list = ( + [networks] if isinstance(networks, str) else (networks or list(CARDANO_NETWORKS)) + ) + for network in networks_list: + client.register(network, scheme) + + if policies: + for policy in policies: + client.register_policy(policy) + return client + + +def register_exact_cardano_server( + server: ServerT, + networks: str | list[str] | None = None, +) -> ServerT: + """Register the Cardano server scheme on an x402 resource server. + + Args: + server: The x402ResourceServer instance. + networks: Optional network(s); defaults to every supported Cardano + network registered individually. + + Returns: + The server for chaining. + """ + from .server import ExactCardanoScheme as ServerScheme + + scheme = ServerScheme() + networks_list = ( + [networks] if isinstance(networks, str) else (networks or list(CARDANO_NETWORKS)) + ) + for network in networks_list: + server.register(network, scheme) + return server + + +def register_exact_cardano_facilitator( + facilitator: FacilitatorT, + signer: FacilitatorCardanoSigner, + networks: str | list[str] | None = None, + *, + accept_mempool: bool = False, +) -> FacilitatorT: + """Register the Cardano facilitator scheme on an x402 facilitator. + + Args: + facilitator: The x402Facilitator instance. + signer: Chain query / submission implementation. + networks: Optional specific network(s); defaults to every supported + Cardano network registered individually so unsupported networks + (e.g. sanchonet) cannot accidentally dispatch via the wildcard + pattern. + accept_mempool: When True, settlement succeeds on mempool inclusion + alone. Defaults to False. + + Returns: + The facilitator for chaining. + """ + from .facilitator import ExactCardanoScheme as FacilitatorScheme + + networks_list = ( + [networks] if isinstance(networks, str) else (networks or list(CARDANO_NETWORKS)) + ) + scheme = FacilitatorScheme(signer, accept_mempool=accept_mempool) + for network in networks_list: + facilitator.register([network], scheme) + return facilitator diff --git a/python/x402/mechanisms/cardano/exact/server.py b/python/x402/mechanisms/cardano/exact/server.py new file mode 100644 index 0000000000..798b52247b --- /dev/null +++ b/python/x402/mechanisms/cardano/exact/server.py @@ -0,0 +1,164 @@ +"""Cardano server implementation for the Exact payment scheme (V2).""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any + +from ....schemas import ( + AssetAmount, + Network, + PaymentRequirements, + Price, + SupportedKind, +) +from ..constants import ( + CARDANO_ASSET_REGEX, + SCHEME_EXACT, + USDM_DEFAULT_DECIMALS, + get_default_usdm_asset, + is_cardano_network, +) +from ..utils import convert_to_token_amount, parse_money_to_decimal + +MoneyParser = Callable[[float, str], AssetAmount | None] + +_ASSET_RE = re.compile(CARDANO_ASSET_REGEX) + + +class ExactCardanoScheme: + """Cardano server-side implementation for the Exact scheme (V2). + + Parses prices and enhances payment requirements. Most Cardano-specific + extras (Masumi, script) are produced by the operator at request time, so + the server passes the supportedKind extras through unmodified. + + Attributes: + scheme: Scheme identifier ("exact"). + """ + + scheme = SCHEME_EXACT + + def __init__(self) -> None: + """Create the server scheme.""" + self._money_parsers: list[MoneyParser] = [] + + def register_money_parser(self, parser: MoneyParser) -> ExactCardanoScheme: + """Register a custom Money parser. + + Parsers are tried in registration order; the first non-None result + wins. Returning None defers to the next parser. + + Args: + parser: The parser to register. + + Returns: + Self for chaining. + """ + self._money_parsers.append(parser) + return self + + def parse_price(self, price: Price, network: Network) -> AssetAmount: + """Convert a Price into an AssetAmount. + + Args: + price: The price to parse (Money or AssetAmount). + network: The Cardano network identifier. + + Returns: + The resolved AssetAmount. + + Raises: + ValueError: If the input is invalid for Cardano. + """ + if isinstance(price, dict) and "amount" in price: + asset = price.get("asset") + if not asset: + raise ValueError( + f"Asset unit must be specified for AssetAmount on network {network}" + ) + if not _ASSET_RE.match(asset): + raise ValueError(f"Invalid Cardano asset unit: {asset}") + return AssetAmount(amount=price["amount"], asset=asset, extra=price.get("extra") or {}) + if isinstance(price, AssetAmount): + if not price.asset or not _ASSET_RE.match(price.asset): + raise ValueError(f"Invalid Cardano asset unit: {price.asset}") + return price + + decimal = parse_money_to_decimal(price) + for parser in self._money_parsers: + result = parser(decimal, str(network)) + if result is not None: + if not _ASSET_RE.match(result.asset): + raise ValueError( + f"Custom money parser returned invalid Cardano asset: {result.asset}" + ) + return result + return self._default_money_conversion(decimal, str(network)) + + def enhance_payment_requirements( + self, + requirements: PaymentRequirements, + supported_kind: SupportedKind, + extension_keys: list[str], + ) -> PaymentRequirements: + """Pass facilitator extra metadata through to the client. + + Args: + requirements: Base payment requirements. + supported_kind: SupportedKind from the facilitator. + extension_keys: Extension keys (unused for Cardano default). + + Returns: + Enhanced payment requirements. + """ + _ = extension_keys + if not is_cardano_network(str(supported_kind.network)): + raise ValueError(f"Unsupported Cardano network: {supported_kind.network}") + # Merge supported_kind extras with whatever the requirements already + # carry; requirements take precedence so server-side overrides are + # respected. + if requirements.extra is None: + requirements.extra = {} + merged: dict[str, Any] = {} + for key, value in (supported_kind.extra or {}).items(): + merged[key] = value + for key, value in (requirements.extra or {}).items(): + merged[key] = value + requirements.extra = merged + return requirements + + def get_asset_decimals(self, asset: str, network: Network) -> int: + """Return the decimal precision for the supplied asset. + + Default implementation returns USDM/USDC decimals (6); integrators + with custom tokens should subclass. + + Args: + asset: Asset unit string. + network: Network identifier. + + Returns: + Decimal precision for the asset (default 6). + """ + _ = asset, network + return USDM_DEFAULT_DECIMALS + + def _default_money_conversion(self, decimal_amount: float, network: str) -> AssetAmount: + """Convert a Money decimal to USDM atomic units. + + Honors `get_asset_decimals()` so subclasses that override the hook + for non-USDM tokens get correctly scaled atomic amounts. + + Args: + decimal_amount: Decimal amount (e.g. 1.50). + network: The Cardano network identifier. + + Returns: + AssetAmount in default-asset smallest units. + """ + asset = get_default_usdm_asset(network) + decimals = self.get_asset_decimals(asset, network) + atomic = convert_to_token_amount(str(decimal_amount), decimals) + return AssetAmount(amount=atomic, asset=asset, extra={}) diff --git a/python/x402/mechanisms/cardano/signer.py b/python/x402/mechanisms/cardano/signer.py new file mode 100644 index 0000000000..3deec6a11c --- /dev/null +++ b/python/x402/mechanisms/cardano/signer.py @@ -0,0 +1,134 @@ +"""Signer protocols for the Cardano `exact` mechanism.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from .types import CardanoSubmissionResult, CardanoUtxoSnapshot + + +class ClientCardanoSigner(Protocol): + """Client-side Cardano signer. + + Implementations integrate the user's Cardano wallet / key management. The + signer receives the desired payment requirements and returns a + base64-encoded signed Cardano transaction along with the UTXO reference + used as nonce. + """ + + def get_address(self) -> str: + """Return the bech32 address that will fund the payment. + + Returns: + The bech32 payment address. + """ + ... + + def sign_payment_transaction( + self, + *, + network: str, + pay_to: str, + asset: str, + amount: str, + max_timeout_seconds: int, + extra: dict[str, Any] | None, + ) -> dict[str, str]: + """Build and sign a Cardano transaction satisfying the supplied + payment requirements. + + Args: + network: x402 Cardano network identifier. + pay_to: Recipient bech32 address. + asset: Asset unit (`policyId.assetNameHex`). + amount: Amount in the asset's smallest unit. + max_timeout_seconds: Transaction lifetime hint. + extra: Optional extra block (assetTransferMethod metadata). + + Returns: + Dict with `transaction` (base64 CBOR) and `nonce` (`txHash#index`). + """ + ... + + +class FacilitatorCardanoSigner(Protocol): + """Facilitator-side Cardano signer / chain query protocol. + + The implementation provides chain reads (for nonce/TTL verification) and + chain writes (for settlement). Verification rule 5 needs `get_utxo`, + rule 6 needs `get_current_slot`, and settlement needs `submit_transaction`. + """ + + def get_addresses(self) -> list[str]: + """Return the addresses managed by this facilitator. + + Returns: + The list of bech32 addresses for `/supported`. + """ + ... + + def get_utxo(self, ref: str, network: str) -> CardanoUtxoSnapshot: + """Look up a single UTXO by reference. + + Args: + ref: UTXO reference (`txHashHex#index`). + network: The x402 network identifier. + + Returns: + A snapshot describing the UTXO presence. + """ + ... + + def get_current_slot(self, network: str) -> int: + """Return the current absolute slot number for the supplied network. + + Args: + network: The x402 network identifier. + + Returns: + The current absolute slot number. + """ + ... + + def submit_transaction( + self, signed_transaction_base64: str, network: str + ) -> CardanoSubmissionResult: + """Submit a fully signed transaction to the chain. + + Args: + signed_transaction_base64: The base64-encoded CBOR transaction. + network: The x402 network identifier. + + Returns: + The submission result. + """ + ... + + +class FacilitatorCardanoSignerWithEvaluate(FacilitatorCardanoSigner, Protocol): + """Optional capability: a `FacilitatorCardanoSigner` that can also dry-run + a signed transaction against a Cardano node. + + The base `FacilitatorCardanoSigner` does NOT require `evaluate_transaction` + so consumers can implement only the four core operations and still pass + static type checking. `ExactCardanoFacilitatorScheme.verify()` detects + this method via `getattr` at runtime; signers that implement it gain a + full cryptographic authorization check inside verify(), beyond the + structural witness-presence guard. + + The TS counterpart marks the same hook with `evaluateTransaction?(...)` + on `FacilitatorCardanoSigner`; Python expresses the same "optional method" + contract through this opt-in subclass-Protocol pattern. + """ + + def evaluate_transaction(self, signed_transaction_base64: str, network: str) -> None: + """Ask a Cardano node / evaluation service to dry-run the signed + transaction. Implementations should raise on rejection; the + exception's str() is surfaced as `invalid_message` on the verify + response. + + Args: + signed_transaction_base64: The base64-encoded CBOR transaction. + network: The x402 network identifier. + """ + ... diff --git a/python/x402/mechanisms/cardano/types.py b/python/x402/mechanisms/cardano/types.py new file mode 100644 index 0000000000..5fa03981a6 --- /dev/null +++ b/python/x402/mechanisms/cardano/types.py @@ -0,0 +1,139 @@ +"""Cardano-specific payload, extra, and decoded transaction types.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, TypedDict + + +@dataclass +class ExactCardanoPayload: + """Payload carried inside a Cardano `exact` PaymentPayload. + + `transaction` is a base64-encoded, fully signed Cardano CBOR transaction. + `nonce` is a UTXO reference (`txHashHex#index`) that MUST also appear as a + transaction input. The facilitator uses the nonce to enforce uniqueness + and replay protection (rule 5 in the spec). + """ + + transaction: str + nonce: str + + def to_dict(self) -> dict[str, Any]: + """Convert to a JSON-serializable dict. + + Returns: + Dict with `transaction` and `nonce` keys. + """ + return {"transaction": self.transaction, "nonce": self.nonce} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ExactCardanoPayload: + """Create from a payload dictionary. + + Args: + data: Dict with `transaction` and `nonce` fields. + + Returns: + The typed payload. + + Raises: + ValueError: If required fields are missing or empty. + """ + transaction = data.get("transaction") + nonce = data.get("nonce") + if not isinstance(transaction, str) or not transaction: + raise ValueError("Cardano payload is missing a transaction string") + if not isinstance(nonce, str) or not nonce: + raise ValueError("Cardano payload is missing a nonce string") + return cls(transaction=transaction, nonce=nonce) + + +class CardanoExtraDefault(TypedDict, total=False): + """`extra` shape for the default address-to-address Cardano scheme.""" + + assetTransferMethod: Literal["default"] + + +class CardanoExtraMasumi(TypedDict, total=False): + """`extra` shape for Masumi smart-protocol payments.""" + + assetTransferMethod: Literal["masumi"] + identifierFromPurchaser: str + sellerVkey: str + paymentType: str + blockchainIdentifier: str + payByTime: str + submitResultTime: str + unlockTime: str + externalDisputeUnlockTime: str + agentIdentifier: str + inputHash: str + + +class CardanoScriptDescriptor(TypedDict): + """Plutus script descriptor used in the script asset-transfer method.""" + + type: Literal["plutusV1", "plutusV2", "plutusV3"] + code: str # hex + + +class CardanoScriptParameter(TypedDict): + """Single parameter applied to a Plutus script during transaction building.""" + + type: Literal["bytes", "bigint", "integer", "string", "constr", "list", "map", "boolean"] + value: Any + + +class CardanoExtraScript(TypedDict, total=False): + """`extra` shape for the script asset-transfer method.""" + + assetTransferMethod: Literal["script"] + scriptHash: str + script: CardanoScriptDescriptor + parameters: dict[str, CardanoScriptParameter] + + +@dataclass +class CardanoUtxoOutput: + """SDK-agnostic decoded view of a Cardano UTXO output.""" + + address: str + coin: int # lovelace + assets: dict[str, int] = field(default_factory=dict) # `${policy}.${name}` -> qty + + +@dataclass +class DecodedCardanoTransaction: + """Decoded view of the parts of a Cardano transaction body needed for + facilitator verification. + """ + + tx_hash: str + network_id: int | None + ttl_slot: int | None + validity_start_slot: int | None + inputs: list[str] # `txHashHex#index` + outputs: list[CardanoUtxoOutput] + # Number of vkey + bootstrap witnesses on the transaction. Verifier rejects + # the payment when both this and `script_witness_count` are zero (unsigned + # transaction). + vkey_witness_count: int = 0 + # Number of native + plutus script witnesses (incl. redeemers). + script_witness_count: int = 0 + + +@dataclass +class CardanoUtxoSnapshot: + """Lightweight UTXO snapshot returned by chain queries.""" + + exists: bool + address: str | None = None + + +@dataclass +class CardanoSubmissionResult: + """Outcome of submitting a transaction to the Cardano network.""" + + tx_hash: str + status: Literal["confirmed", "mempool"] diff --git a/python/x402/mechanisms/cardano/utils.py b/python/x402/mechanisms/cardano/utils.py new file mode 100644 index 0000000000..b8d25caff4 --- /dev/null +++ b/python/x402/mechanisms/cardano/utils.py @@ -0,0 +1,294 @@ +"""Utility helpers for the Cardano `exact` mechanism.""" + +from __future__ import annotations + +import base64 +import re +from decimal import Decimal +from typing import Any + +from .constants import ( + CARDANO_ASSET_REGEX, + CARDANO_UTXO_REF_REGEX, + ERR_CARDANO_SDK_MISSING, +) +from .types import ( + CardanoUtxoOutput, + DecodedCardanoTransaction, + ExactCardanoPayload, +) + +_ASSET_RE = re.compile(CARDANO_ASSET_REGEX) +_UTXO_RE = re.compile(CARDANO_UTXO_REF_REGEX) + + +class CardanoSdkMissingError(RuntimeError): + """Raised when pycardano (the optional SDK) is not installed.""" + + +def parse_asset_unit(asset: str) -> tuple[str, str]: + """Split a Cardano asset unit. + + Accepts either the literal `"lovelace"` (native ADA — returned as + `("", "")`) or a `policyId.assetNameHex` pair. + + Args: + asset: The asset unit string. + + Returns: + Tuple `(policy_id, asset_name_hex)`, both lowercase. For lovelace + both elements are empty strings. + + Raises: + ValueError: If the input is not a valid asset unit. + """ + if not _ASSET_RE.match(asset): + raise ValueError(f"Invalid Cardano asset unit: {asset}") + if asset.lower() == "lovelace": + return "", "" + policy_id, asset_name_hex = asset.split(".", 1) + return policy_id.lower(), asset_name_hex.lower() + + +def parse_utxo_ref(ref: str) -> tuple[str, int]: + """Parse a UTXO reference (`txHashHex#index`). + + Args: + ref: The UTXO reference. + + Returns: + Tuple `(tx_hash, index)`. + + Raises: + ValueError: If the input is not a valid reference. + """ + if not _UTXO_RE.match(ref): + raise ValueError(f"Invalid Cardano UTXO reference: {ref}") + tx_hash, index_str = ref.split("#", 1) + return tx_hash.lower(), int(index_str) + + +def encode_cardano_payload(payload: ExactCardanoPayload) -> dict[str, Any]: + """Serialize a payload as a JSON-friendly dict. + + Args: + payload: The payload to serialize. + + Returns: + Dict with `transaction` and `nonce` fields. + """ + return payload.to_dict() + + +def decode_cardano_payload(raw: dict[str, Any]) -> ExactCardanoPayload: + """Deserialize a payload from an arbitrary dict. + + Args: + raw: Raw payload coming from the x402 envelope. + + Returns: + The typed Cardano payload. + + Raises: + ValueError: When required fields are missing. + """ + return ExactCardanoPayload.from_dict(raw) + + +def output_satisfies(output: CardanoUtxoOutput, recipient: str, asset: str, amount: int) -> bool: + """Return True when `output` pays at least `amount` of `asset` to + `recipient`. + + Lovelace (native ADA) is special-cased: the helper compares against + `output.coin` rather than the multi-asset map. + + Args: + output: The decoded UTXO output. + recipient: Required bech32 recipient. + asset: Asset unit (`policyId.assetNameHex`) or the literal + `"lovelace"`. + amount: Required amount in smallest unit. + + Returns: + True if the output satisfies the requirement. + """ + if output.address != recipient: + return False + if asset.lower() == "lovelace": + return output.coin >= amount + available = output.assets.get(asset.lower(), 0) + return available >= amount + + +def parse_money_to_decimal(money: str | float | int) -> float: + """Parse a Money value into a decimal number. + + Handles `$1.50`, `1.50`, `1.5 USDM`, and numeric inputs. + + Args: + money: Money value as string or number. + + Returns: + Decimal amount as float. + """ + if isinstance(money, (int, float)): + return float(money) + cleaned = money.strip().lstrip("$") + cleaned = re.sub(r"\s*(USD|USDC|USDM)\s*$", "", cleaned, flags=re.IGNORECASE).strip() + return float(cleaned) + + +def convert_to_token_amount(decimal_amount: str, decimals: int) -> str: + """Convert a decimal string amount to atomic units. + + Args: + decimal_amount: Decimal amount (e.g. "0.10"). + decimals: Token decimals. + + Returns: + Amount in smallest unit, as a string. + + Raises: + ValueError: If the amount is invalid. + """ + try: + amount = Decimal(decimal_amount) + except Exception as exc: + raise ValueError(f"Invalid amount: {decimal_amount}") from exc + return str(int(amount * Decimal(10**decimals))) + + +def _load_pycardano() -> Any: + """Lazily import pycardano, surfacing a clear error when it is missing. + + Returns: + The pycardano module. + + Raises: + CardanoSdkMissingError: When pycardano is not installed. + """ + try: + import pycardano # type: ignore # noqa: F401 + + return pycardano + except ImportError as cause: # pragma: no cover - import guard + raise CardanoSdkMissingError( + f"{ERR_CARDANO_SDK_MISSING}: install 'pycardano>=0.10.0' to enable " + "Cardano transaction verification (pip install x402[cardano])" + ) from cause + + +def decode_cardano_transaction(transaction_base64: str) -> DecodedCardanoTransaction: + """Decode a base64 signed Cardano transaction into a structural summary. + + Uses the optional pycardano SDK; raises CardanoSdkMissingError when + pycardano is not installed. + + Args: + transaction_base64: The base64-encoded CBOR transaction. + + Returns: + The decoded summary used by the facilitator. + + Raises: + CardanoSdkMissingError: When pycardano is not installed. + ValueError: When the transaction cannot be decoded. + """ + pycardano = _load_pycardano() + + try: + tx_bytes = base64.b64decode(transaction_base64) + except Exception as exc: + raise ValueError("Cardano transaction is not valid base64") from exc + + try: + tx = pycardano.Transaction.from_cbor(tx_bytes) # type: ignore[attr-defined] + except Exception as exc: + raise ValueError("Cardano transaction CBOR could not be decoded") from exc + + body = tx.transaction_body + tx_hash = body.hash().hex() # blake2b-256 + + network_id = getattr(body, "network_id", None) + if hasattr(network_id, "value"): + network_id = int(network_id.value) + elif network_id is not None: + try: + network_id = int(network_id) + except (TypeError, ValueError): + network_id = None + + ttl_slot = body.ttl + if ttl_slot is not None: + ttl_slot = int(ttl_slot) + validity_start_slot = getattr(body, "validity_start", None) + if validity_start_slot is not None: + validity_start_slot = int(validity_start_slot) + + inputs: list[str] = [] + for inp in body.inputs: + # pycardano TransactionInput exposes transaction_id (TransactionId) + index. + h = inp.transaction_id.payload.hex() + idx = int(inp.index) + inputs.append(f"{h}#{idx}") + + outputs: list[CardanoUtxoOutput] = [] + for out in body.outputs: + address = str(out.address) # bech32 + amount = out.amount + coin = int(getattr(amount, "coin", amount)) + assets: dict[str, int] = {} + multi = getattr(amount, "multi_asset", None) + if multi: + for policy_id, name_map in multi.data.items(): + policy_hex = policy_id.payload.hex() + for asset_name, qty in name_map.items(): + name_hex = asset_name.payload.hex() + assets[f"{policy_hex}.{name_hex}".lower()] = int(qty) + outputs.append(CardanoUtxoOutput(address=address, coin=coin, assets=assets)) + + vkey_count, script_count = _count_witnesses(tx) + + return DecodedCardanoTransaction( + tx_hash=tx_hash, + network_id=network_id if isinstance(network_id, int) else None, + ttl_slot=ttl_slot, + validity_start_slot=validity_start_slot, + inputs=inputs, + outputs=outputs, + vkey_witness_count=vkey_count, + script_witness_count=script_count, + ) + + +def _count_witnesses(tx: Any) -> tuple[int, int]: + """Count vkey/bootstrap and script witnesses on a pycardano transaction. + + Args: + tx: The pycardano Transaction. + + Returns: + Tuple `(vkey_witness_count, script_witness_count)`. + """ + ws = tx.transaction_witness_set + vkey_count = 0 + script_count = 0 + for attr in ("vkey_witnesses", "bootstrap_witness"): + seq = getattr(ws, attr, None) or [] + try: + vkey_count += len(seq) + except TypeError: + pass + for attr in ( + "native_scripts", + "plutus_v1_script", + "plutus_v2_script", + "plutus_v3_script", + "redeemer", + ): + seq = getattr(ws, attr, None) or [] + try: + script_count += len(seq) + except TypeError: + pass + return vkey_count, script_count diff --git a/python/x402/pyproject.toml b/python/x402/pyproject.toml index 1b6a209bd3..20ced79354 100644 --- a/python/x402/pyproject.toml +++ b/python/x402/pyproject.toml @@ -46,6 +46,9 @@ svm = [ "solders>=0.27.0", "solana>=0.36.0", ] +cardano = [ + "pycardano>=0.10.0", +] # MCP (Model Context Protocol) integration mcp = ["mcp>=1.0.0"] @@ -56,8 +59,8 @@ extensions = ["jsonschema>=4.0.0"] # Convenience bundles clients = ["x402[httpx,requests]"] servers = ["x402[flask,fastapi]"] -mechanisms = ["x402[evm,svm]"] -all = ["x402[httpx,requests,flask,fastapi,evm,svm,mcp,extensions]"] +mechanisms = ["x402[evm,svm,cardano]"] +all = ["x402[httpx,requests,flask,fastapi,evm,svm,cardano,mcp,extensions]"] [dependency-groups] dev = [ diff --git a/python/x402/schemas/responses.py b/python/x402/schemas/responses.py index 6882f4e859..a15e1e9b7a 100644 --- a/python/x402/schemas/responses.py +++ b/python/x402/schemas/responses.py @@ -62,6 +62,8 @@ class SettleResponse(BaseX402Model): payer: The payer's address. transaction: Transaction hash/identifier. network: Network where settlement occurred. + extensions: Optional scheme-specific extension data (e.g. Cardano + settlement status: "confirmed" | "mempool"). """ success: bool @@ -70,6 +72,7 @@ class SettleResponse(BaseX402Model): payer: str | None = None transaction: str network: Network + extensions: dict[str, Any] | None = None amount: str | None = None diff --git a/python/x402/tests/unit/mechanisms/cardano/__init__.py b/python/x402/tests/unit/mechanisms/cardano/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/x402/tests/unit/mechanisms/cardano/test_constants.py b/python/x402/tests/unit/mechanisms/cardano/test_constants.py new file mode 100644 index 0000000000..33bac0d117 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/cardano/test_constants.py @@ -0,0 +1,64 @@ +"""Unit tests for cardano constants.""" + +import pytest + +from x402.mechanisms.cardano import ( + CARDANO_MAINNET_CAIP2, + CARDANO_NETWORKS, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, + SCHEME_EXACT, + USDM_MAINNET_ASSET, + USDM_MAINNET_POLICY_ID, + USDM_PREPROD_ASSET, + get_cardano_network_id, + get_default_usdm_asset, + is_cardano_network, +) + + +def test_network_identifiers_match_spec(): + """The package uses the spec's verbatim network identifiers.""" + assert CARDANO_MAINNET_CAIP2 == "cardano:mainnet" + assert CARDANO_PREPROD_CAIP2 == "cardano:preprod" + assert CARDANO_PREVIEW_CAIP2 == "cardano:preview" + assert CARDANO_NETWORKS == [ + CARDANO_MAINNET_CAIP2, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, + ] + + +def test_scheme_identifier(): + assert SCHEME_EXACT == "exact" + + +def test_usdm_defaults_match_spec(): + assert len(USDM_MAINNET_POLICY_ID) == 56 + assert ( + USDM_MAINNET_ASSET + == "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d" + ) + assert get_default_usdm_asset(CARDANO_MAINNET_CAIP2) == USDM_MAINNET_ASSET + assert get_default_usdm_asset(CARDANO_PREPROD_CAIP2) == USDM_PREPROD_ASSET + + +def test_get_cardano_network_id(): + assert get_cardano_network_id(CARDANO_MAINNET_CAIP2) == 1 + assert get_cardano_network_id(CARDANO_PREPROD_CAIP2) == 0 + assert get_cardano_network_id(CARDANO_PREVIEW_CAIP2) == 0 + with pytest.raises(ValueError, match="Unsupported Cardano network"): + get_cardano_network_id("ethereum:1") + + +def test_is_cardano_network(): + assert is_cardano_network("cardano:mainnet") is True + assert is_cardano_network("cardano:preprod") is True + assert is_cardano_network("cardano:preview") is True + assert is_cardano_network("cardano:sanchonet") is False + assert is_cardano_network("ethereum:1") is False + + +def test_get_default_usdm_asset_raises_for_preview(): + with pytest.raises(ValueError, match="No default USDM asset configured"): + get_default_usdm_asset(CARDANO_PREVIEW_CAIP2) diff --git a/python/x402/tests/unit/mechanisms/cardano/test_scheme.py b/python/x402/tests/unit/mechanisms/cardano/test_scheme.py new file mode 100644 index 0000000000..3816e94943 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/cardano/test_scheme.py @@ -0,0 +1,453 @@ +"""Unit tests for the Cardano exact scheme classes.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from x402.mechanisms.cardano import ( + CARDANO_MAINNET_CAIP2, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, + USDM_MAINNET_ASSET, + USDM_PREPROD_ASSET, + CardanoSubmissionResult, + CardanoUtxoSnapshot, +) +from x402.mechanisms.cardano.exact import ( + ExactCardanoClientScheme, + ExactCardanoFacilitatorScheme, + ExactCardanoServerScheme, +) +from x402.schemas import ( + AssetAmount, + PaymentPayload, + PaymentRequirements, + SupportedKind, +) + +TX_HASH = "a" * 64 +RECIPIENT = "addr1qxytestrecipientaddress00" + + +def make_requirements(**overrides: Any) -> PaymentRequirements: + """Build payment requirements with sensible Cardano defaults. + + Args: + **overrides: Field overrides. + + Returns: + Payment requirements ready for tests. + """ + fields: dict[str, Any] = { + "scheme": "exact", + "network": CARDANO_MAINNET_CAIP2, + "asset": USDM_MAINNET_ASSET, + "amount": "10000", + "pay_to": RECIPIENT, + "max_timeout_seconds": 600, + "extra": {}, + } + fields.update(overrides) + return PaymentRequirements(**fields) + + +class StubClientSigner: + """Stub Cardano client signer for unit tests.""" + + def get_address(self) -> str: + return "addr1qxsomeaddress00" + + def sign_payment_transaction(self, **_: Any) -> dict[str, str]: + return {"transaction": "AAAA", "nonce": f"{TX_HASH}#0"} + + +class StubFacilitatorSigner: + """Stub Cardano facilitator signer for unit tests.""" + + def __init__( + self, + *, + exists: bool = True, + address: str = "addr1qpayer00", + current_slot: int = 100, + status: str = "confirmed", + ): + self._exists = exists + self._address = address + self._current_slot = current_slot + self._status = status + self.submitted: list[str] = [] + + def get_addresses(self) -> list[str]: + return ["addr1qfacilitator00"] + + def get_utxo(self, ref: str, network: str) -> CardanoUtxoSnapshot: + return CardanoUtxoSnapshot(exists=self._exists, address=self._address) + + def get_current_slot(self, network: str) -> int: + return self._current_slot + + def submit_transaction( + self, signed_transaction_base64: str, network: str + ) -> CardanoSubmissionResult: + self.submitted.append(signed_transaction_base64) + return CardanoSubmissionResult(tx_hash="deadbeef", status=self._status) + + +# ============================================================================= +# Client tests +# ============================================================================= + + +def test_client_rejects_non_cardano_network(): + client = ExactCardanoClientScheme(StubClientSigner()) + with pytest.raises(ValueError, match="Unsupported Cardano network"): + client.create_payment_payload(make_requirements(network="ethereum:1")) + + +def test_client_rejects_invalid_address(): + client = ExactCardanoClientScheme(StubClientSigner()) + with pytest.raises(ValueError, match="Invalid Cardano pay-to address"): + client.create_payment_payload(make_requirements(pay_to="0xnope")) + + +def test_client_rejects_invalid_asset(): + client = ExactCardanoClientScheme(StubClientSigner()) + with pytest.raises(ValueError, match="Invalid Cardano asset unit"): + client.create_payment_payload(make_requirements(asset="not.a.unit")) + + +def test_client_rejects_non_integer_amount(): + client = ExactCardanoClientScheme(StubClientSigner()) + with pytest.raises(ValueError, match="Amount must be a non-negative integer"): + client.create_payment_payload(make_requirements(amount="10.5")) + + +def test_client_returns_signed_payload_for_valid_inputs(): + client = ExactCardanoClientScheme(StubClientSigner()) + payload = client.create_payment_payload(make_requirements()) + assert payload == {"transaction": "AAAA", "nonce": f"{TX_HASH}#0"} + + +def test_client_rejects_invalid_signer_response(): + class BadSigner: + def get_address(self) -> str: + return "addr1q" + + def sign_payment_transaction(self, **_: Any) -> dict[str, str]: + return {"transaction": "AAAA", "nonce": "bad"} + + client = ExactCardanoClientScheme(BadSigner()) + with pytest.raises(RuntimeError, match="invalid nonce"): + client.create_payment_payload(make_requirements()) + + +# ============================================================================= +# Facilitator tests +# ============================================================================= + + +def test_facilitator_declares_caip_family(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + assert facilitator.scheme == "exact" + assert facilitator.caip_family == "cardano:*" + + +def test_facilitator_get_signers(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + assert facilitator.get_signers(CARDANO_MAINNET_CAIP2) == ["addr1qfacilitator00"] + + +def test_facilitator_get_extra_returns_none(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + assert facilitator.get_extra(CARDANO_PREPROD_CAIP2) is None + + +def test_facilitator_rejects_network_mismatch(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(network=CARDANO_PREVIEW_CAIP2), + payload={"transaction": "AA", "nonce": f"{TX_HASH}#0"}, + ) + response = facilitator.verify(payload, make_requirements()) + assert response.is_valid is False + assert response.invalid_reason == "network_mismatch" + + +def test_facilitator_rejects_non_cardano_network(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + reqs = make_requirements(network="ethereum:1") + payload = PaymentPayload( + x402_version=2, + accepted=reqs, + payload={"transaction": "AA", "nonce": f"{TX_HASH}#0"}, + ) + response = facilitator.verify(payload, reqs) + assert response.is_valid is False + assert response.invalid_reason == "network_mismatch" + + +def test_facilitator_rejects_invalid_payload(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(), + payload={"nonce": f"{TX_HASH}#0"}, # missing transaction + ) + response = facilitator.verify(payload, make_requirements()) + assert response.is_valid is False + assert response.invalid_reason == "invalid_exact_cardano_payload" + + +def test_facilitator_rejects_invalid_nonce(): + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(), + payload={"transaction": "AA", "nonce": "bad"}, + ) + response = facilitator.verify(payload, make_requirements()) + assert response.is_valid is False + assert response.invalid_reason == "invalid_exact_cardano_payload_nonce_invalid" + + +def test_facilitator_rejects_script_method_in_base_class(): + """Base class refuses script payments because it cannot reconstruct the + script address. Integrators must override `_run_method_specific_checks`. + """ + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + ok, reason = facilitator._run_method_specific_checks( + {"assetTransferMethod": "script", "scriptHash": "deadbeef"}, + RECIPIENT, + "addr1qpayer00", + ) + assert ok is False + assert reason == "invalid_exact_cardano_payload_script_address_mismatch" + + +def test_facilitator_settle_rejects_mempool_when_not_accepted(): + """Default settle() refuses mempool-only inclusion.""" + + class _Facilitator(ExactCardanoFacilitatorScheme): + def verify(self, payload, requirements, context=None): + from x402.schemas import VerifyResponse + + return VerifyResponse(is_valid=True, payer="addr1qpayer00") + + signer = StubFacilitatorSigner(status="mempool") + facilitator = _Facilitator(signer) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(), + payload={"transaction": "AAAA", "nonce": f"{TX_HASH}#0"}, + ) + response = facilitator.settle(payload, make_requirements()) + assert response.success is False + assert response.error_reason == "exact_cardano_settlement_not_confirmed" + assert response.transaction == "deadbeef" + + +def test_facilitator_reads_method_from_canonical_requirements_extra(monkeypatch): + """Script-mode rejection must use the canonical server-side requirements + extra, not the client-echoed `payload.accepted.extra`. + """ + captured: dict[str, dict[str, Any] | None] = {} + + class _Facilitator(ExactCardanoFacilitatorScheme): + def _run_method_specific_checks(self, extra, pay_to, payer): + captured["extra"] = dict(extra) if extra else None + return True, "" + + facilitator = _Facilitator(StubFacilitatorSigner()) + + # Stub the CSL-bound transaction decode so the test runs without pycardano. + from x402.mechanisms.cardano.types import ( + CardanoUtxoOutput, + DecodedCardanoTransaction, + ) + + decoded = DecodedCardanoTransaction( + tx_hash="abc", + network_id=1, + ttl_slot=None, + validity_start_slot=None, + inputs=[f"{TX_HASH}#0"], + outputs=[ + CardanoUtxoOutput( + address=RECIPIENT, + coin=0, + assets={USDM_MAINNET_ASSET.lower(): 10_000}, + ) + ], + vkey_witness_count=1, + script_witness_count=0, + ) + monkeypatch.setattr( + "x402.mechanisms.cardano.exact.facilitator.decode_cardano_transaction", + lambda _: decoded, + ) + + server_requirements = make_requirements( + extra={"assetTransferMethod": "script", "scriptHash": "deadbeef"}, + ) + # Client echoes back a benign-looking 'default' to try to bypass. + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(extra={"assetTransferMethod": "default"}), + payload={"transaction": "AAAA", "nonce": f"{TX_HASH}#0"}, + ) + + facilitator.verify(payload, server_requirements) + assert captured["extra"] == { + "assetTransferMethod": "script", + "scriptHash": "deadbeef", + } + + +def test_facilitator_rejects_unsigned_transactions(monkeypatch): + """Verify must reject transactions with no witnesses (unsigned).""" + facilitator = ExactCardanoFacilitatorScheme(StubFacilitatorSigner()) + + from x402.mechanisms.cardano.types import ( + CardanoUtxoOutput, + DecodedCardanoTransaction, + ) + + decoded = DecodedCardanoTransaction( + tx_hash="abc", + network_id=1, + ttl_slot=None, + validity_start_slot=None, + inputs=[f"{TX_HASH}#0"], + outputs=[ + CardanoUtxoOutput( + address=RECIPIENT, + coin=0, + assets={USDM_MAINNET_ASSET.lower(): 10_000}, + ) + ], + vkey_witness_count=0, # unsigned! + script_witness_count=0, + ) + monkeypatch.setattr( + "x402.mechanisms.cardano.exact.facilitator.decode_cardano_transaction", + lambda _: decoded, + ) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(), + payload={"transaction": "AAAA", "nonce": f"{TX_HASH}#0"}, + ) + response = facilitator.verify(payload, make_requirements()) + assert response.is_valid is False + assert response.invalid_reason == "invalid_exact_cardano_payload_unsigned" + + +def test_facilitator_concurrent_settle_blocked_by_claim(): + """A second concurrent settle() must be rejected as duplicate even before + the first submit_transaction returns. + """ + + class _Facilitator(ExactCardanoFacilitatorScheme): + def verify(self, payload, requirements, context=None): + from x402.schemas import VerifyResponse + + return VerifyResponse(is_valid=True, payer="addr1qpayer00") + + facilitator = _Facilitator(StubFacilitatorSigner()) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(), + payload={"transaction": "AAAA", "nonce": f"{TX_HASH}#0"}, + ) + first = facilitator.settle(payload, make_requirements()) + second = facilitator.settle(payload, make_requirements()) + assert first.success is True + assert second.success is False + assert second.error_reason == "duplicate_settlement" + + +def test_facilitator_settle_accepts_mempool_when_enabled(): + class _Facilitator(ExactCardanoFacilitatorScheme): + def verify(self, payload, requirements, context=None): + from x402.schemas import VerifyResponse + + return VerifyResponse(is_valid=True, payer="addr1qpayer00") + + signer = StubFacilitatorSigner(status="mempool") + facilitator = _Facilitator(signer, accept_mempool=True) + payload = PaymentPayload( + x402_version=2, + accepted=make_requirements(), + payload={"transaction": "AAAA", "nonce": f"{TX_HASH}#0"}, + ) + response = facilitator.settle(payload, make_requirements()) + assert response.success is True + assert response.transaction == "deadbeef" + + +# ============================================================================= +# Server tests +# ============================================================================= + + +def test_server_parses_money_strings_to_usdm_atomic_units(): + server = ExactCardanoServerScheme() + result = server.parse_price("$1.50", CARDANO_MAINNET_CAIP2) + assert result.amount == "1500000" + assert result.asset == USDM_MAINNET_ASSET + + +def test_server_passes_through_asset_amount_dict(): + server = ExactCardanoServerScheme() + result = server.parse_price( + {"amount": "12345", "asset": USDM_PREPROD_ASSET, "extra": {"tier": "premium"}}, + CARDANO_PREPROD_CAIP2, + ) + assert result.amount == "12345" + assert result.extra == {"tier": "premium"} + + +def test_server_money_parser_chain(): + server = ExactCardanoServerScheme() + + def vip(amount: float, _network: str): + if amount > 100: + return AssetAmount( + amount=str(int(amount * 1_000_000)), + asset=USDM_MAINNET_ASSET, + extra={"tier": "vip"}, + ) + return None + + server.register_money_parser(vip) + + big = server.parse_price("150", CARDANO_MAINNET_CAIP2) + assert big.extra == {"tier": "vip"} + small = server.parse_price("1", CARDANO_MAINNET_CAIP2) + assert small.amount == "1000000" + assert small.extra == {} + + +def test_server_enhance_payment_requirements_merges_extras(): + server = ExactCardanoServerScheme() + base = make_requirements(extra={"foo": "bar"}) + supported = SupportedKind( + x402_version=2, + scheme="exact", + network=CARDANO_MAINNET_CAIP2, + extra={"policy": "default"}, + ) + enhanced = server.enhance_payment_requirements(base, supported, []) + assert enhanced.extra == {"policy": "default", "foo": "bar"} + + +def test_server_rejects_non_cardano_network_in_enhance(): + server = ExactCardanoServerScheme() + base = make_requirements() + supported = SupportedKind(x402_version=2, scheme="exact", network="ethereum:1", extra={}) + with pytest.raises(ValueError, match="Unsupported Cardano network"): + server.enhance_payment_requirements(base, supported, []) diff --git a/python/x402/tests/unit/mechanisms/cardano/test_utils.py b/python/x402/tests/unit/mechanisms/cardano/test_utils.py new file mode 100644 index 0000000000..52c82a9449 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/cardano/test_utils.py @@ -0,0 +1,108 @@ +"""Unit tests for cardano utils.""" + +import pytest + +from x402.mechanisms.cardano import ( + CardanoSdkMissingError, + CardanoUtxoOutput, + convert_to_token_amount, + decode_cardano_payload, + encode_cardano_payload, + output_satisfies, + parse_asset_unit, + parse_money_to_decimal, + parse_utxo_ref, +) +from x402.mechanisms.cardano.types import ExactCardanoPayload +from x402.mechanisms.cardano.utils import decode_cardano_transaction + +ASSET = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d" +TX_HASH = "a" * 64 + + +def test_parse_asset_unit(): + assert parse_asset_unit(ASSET) == ( + "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad", + "0014df105553444d", + ) + + +def test_parse_asset_unit_rejects_invalid(): + with pytest.raises(ValueError, match="Invalid Cardano asset unit"): + parse_asset_unit("not.a.unit") + + +def test_parse_utxo_ref(): + assert parse_utxo_ref(f"{TX_HASH}#3") == (TX_HASH, 3) + with pytest.raises(ValueError): + parse_utxo_ref(f"{TX_HASH}#-1") + + +def test_payload_round_trip(): + payload = ExactCardanoPayload(transaction="AAAA", nonce=f"{TX_HASH}#0") + encoded = encode_cardano_payload(payload) + decoded = decode_cardano_payload(encoded) + assert decoded.transaction == "AAAA" + assert decoded.nonce == f"{TX_HASH}#0" + + +def test_decode_cardano_payload_rejects_missing_fields(): + with pytest.raises(ValueError): + decode_cardano_payload({}) + with pytest.raises(ValueError): + decode_cardano_payload({"transaction": "AA"}) + + +def test_output_satisfies(): + out = CardanoUtxoOutput(address="addr1payee", coin=0, assets={ASSET.lower(): 12_000}) + assert output_satisfies(out, "addr1payee", ASSET, 10_000) is True + assert output_satisfies(out, "addr1payee", ASSET, 13_000) is False + assert output_satisfies(out, "addr1other", ASSET, 1) is False + + +def test_output_satisfies_lovelace_compares_against_coin(): + out = CardanoUtxoOutput(address="addr1payee", coin=5_000_000) + assert output_satisfies(out, "addr1payee", "lovelace", 5_000_000) is True + assert output_satisfies(out, "addr1payee", "lovelace", 5_000_001) is False + + +def test_parse_asset_unit_accepts_lovelace(): + assert parse_asset_unit("lovelace") == ("", "") + + +def test_parse_money_to_decimal(): + assert parse_money_to_decimal("$1.50") == 1.5 + assert parse_money_to_decimal("1.50 USDM") == 1.5 + assert parse_money_to_decimal(2.5) == 2.5 + + +def test_convert_to_token_amount(): + assert convert_to_token_amount("0.10", 6) == "100000" + assert convert_to_token_amount("1.00", 6) == "1000000" + assert convert_to_token_amount("123.456789", 6) == "123456789" + with pytest.raises(ValueError): + convert_to_token_amount("abc", 6) + + +def test_decode_cardano_transaction_without_pycardano(monkeypatch): + """If pycardano is not importable the helper raises CardanoSdkMissingError. + + Forcing the import to fail simulates a deployment that did not install + the optional `cardano` extra. + """ + import sys + + sys.modules.pop("pycardano", None) + + real_import = ( + __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ + ) + + def fake_import(name, *args, **kwargs): + if name == "pycardano": + raise ImportError("synthetic missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + with pytest.raises(CardanoSdkMissingError): + decode_cardano_transaction("AA==") diff --git a/typescript/packages/mechanisms/cardano/.prettierignore b/typescript/packages/mechanisms/cardano/.prettierignore new file mode 100644 index 0000000000..9fd1bade5d --- /dev/null +++ b/typescript/packages/mechanisms/cardano/.prettierignore @@ -0,0 +1,7 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +**/**/*.json +*.md diff --git a/typescript/packages/mechanisms/cardano/.prettierrc b/typescript/packages/mechanisms/cardano/.prettierrc new file mode 100644 index 0000000000..a0e262813a --- /dev/null +++ b/typescript/packages/mechanisms/cardano/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "arrowParens": "avoid" +} diff --git a/typescript/packages/mechanisms/cardano/README.md b/typescript/packages/mechanisms/cardano/README.md new file mode 100644 index 0000000000..a5f9ef83f8 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/README.md @@ -0,0 +1,55 @@ +# @x402/cardano + +x402 Payment Protocol — Cardano `exact` scheme implementation. + +This package implements the [`exact` scheme on Cardano](../../../../specs/schemes/exact/scheme_exact_cardano.md) for the x402 protocol. It provides: + +- A **client scheme** (`@x402/cardano/exact/client`) that delegates transaction signing to a user-supplied `ClientCardanoSigner`. +- A **facilitator scheme** (`@x402/cardano/exact/facilitator`) that verifies and settles transactions per the spec's six verification rules. +- A **server scheme** (`@x402/cardano/exact/server`) that parses prices, defaults to USDM, and enhances payment requirements. + +## Networks + +The implementation registers the following x402 network identifiers, matching the spec verbatim: + +| Network | Identifier | Cardano Network ID | +| --------------- | ----------------- | ------------------ | +| Mainnet | `cardano:mainnet` | 1 | +| Preprod testnet | `cardano:preprod` | 0 | +| Preview testnet | `cardano:preview` | 0 | + +These identifiers are deliberately human-readable and match the x402 Cardano spec; they are not canonical CAIP-2. + +## Asset format + +Cardano native tokens are identified as `${policyId}.${assetNameHex}`, e.g. USDM Mainnet: + +``` +c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d +``` + +## Optional Cardano serialization library + +This package depends on `@emurgo/cardano-serialization-lib-nodejs` as a _peer dependency_ to keep the TypeScript bundle light. The dep is resolved lazily inside `decodeCardanoTransaction` so the package can be imported without it; verification calls require it. + +```bash +pnpm add @emurgo/cardano-serialization-lib-nodejs +``` + +## Asset transfer methods + +Per spec, three methods can be selected via `requirements.extra.assetTransferMethod`: + +- `default` — address-to-address payments. No extra verification beyond the six core rules. +- `masumi` — Masumi smart-protocol metadata in `extra`. The base verifier accepts the transfer; integrators may layer additional checks via subclassing. +- `script` — script-address payments. Integrators MUST override `runMethodSpecificChecks` to reconstruct the script address and verify it equals `requirements.payTo`. + +## Settlement status + +Cardano uses Ouroboros Praos (probabilistic finality). The default `settle()` returns whatever status the underlying signer reports. Granting access on `mempool` is **strongly discouraged** by the spec. + +## Optional cryptographic authorization check + +The facilitator's structural checks (network, recipient, amount, asset, nonce, TTL, witness presence) are inexpensive but do not prove the supplied witnesses actually authorize the consumed inputs. To close that gap, implement the optional `evaluateTransaction(signedTransactionBase64, network)` method on your `FacilitatorCardanoSigner`; the facilitator will call it after the structural checks pass and treat any thrown error as a verification failure. Typical implementations route this to a Cardano node `evaluate-tx` endpoint or to Blockfrost's `/utils/txs/evaluate`. + +See `specs/schemes/exact/scheme_exact_cardano.md` for the full protocol description. diff --git a/typescript/packages/mechanisms/cardano/eslint.config.js b/typescript/packages/mechanisms/cardano/eslint.config.js new file mode 100644 index 0000000000..d3ffb66bb5 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/eslint.config.js @@ -0,0 +1,92 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts", "**/*.tsx"], + ignores: ["**/*.test.ts", "test/**/*"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + console: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_$" }], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, + { + files: ["**/*.test.ts", "test/**/*"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + }, + rules: { + "prettier/prettier": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/member-ordering": "off", + }, + }, +]; diff --git a/typescript/packages/mechanisms/cardano/package.json b/typescript/packages/mechanisms/cardano/package.json new file mode 100644 index 0000000000..1d80ff9710 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/package.json @@ -0,0 +1,100 @@ +{ + "name": "@x402/cardano", + "version": "2.9.0", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:watch": "vitest", + "watch": "tsc --watch", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", + "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", + "lint": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts" + }, + "keywords": [ + "x402", + "payment", + "protocol", + "cardano" + ], + "license": "Apache-2.0", + "repository": "https://github.com/x402-foundation/x402", + "description": "x402 Payment Protocol Cardano Implementation", + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsup": "^8.4.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vite": "^6.2.6", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.0.5" + }, + "dependencies": { + "@x402/core": "workspace:*" + }, + "peerDependencies": { + "@emurgo/cardano-serialization-lib-nodejs": ">=11.5.0" + }, + "peerDependenciesMeta": { + "@emurgo/cardano-serialization-lib-nodejs": { + "optional": true + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.mts", + "default": "./dist/esm/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./exact/client": { + "import": { + "types": "./dist/esm/exact/client/index.d.mts", + "default": "./dist/esm/exact/client/index.mjs" + }, + "require": { + "types": "./dist/cjs/exact/client/index.d.ts", + "default": "./dist/cjs/exact/client/index.js" + } + }, + "./exact/server": { + "import": { + "types": "./dist/esm/exact/server/index.d.mts", + "default": "./dist/esm/exact/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/exact/server/index.d.ts", + "default": "./dist/cjs/exact/server/index.js" + } + }, + "./exact/facilitator": { + "import": { + "types": "./dist/esm/exact/facilitator/index.d.mts", + "default": "./dist/esm/exact/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/exact/facilitator/index.d.ts", + "default": "./dist/cjs/exact/facilitator/index.js" + } + } + }, + "files": [ + "dist" + ] +} diff --git a/typescript/packages/mechanisms/cardano/src/constants.ts b/typescript/packages/mechanisms/cardano/src/constants.ts new file mode 100644 index 0000000000..3118ec9dc2 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/constants.ts @@ -0,0 +1,216 @@ +/** + * Network identifier for Cardano Mainnet. + * + * The x402 Cardano spec uses human-readable names ("cardano:mainnet") rather + * than canonical CAIP-2 form ("cardano:1" or genesis-hash CAIP-2). Treat these + * as x402-local network identifiers; do not "fix" them to canonical CAIP-2. + */ +export const CARDANO_MAINNET_CAIP2 = "cardano:mainnet"; + +/** + * Network identifier for Cardano Preprod (testnet). + */ +export const CARDANO_PREPROD_CAIP2 = "cardano:preprod"; + +/** + * Network identifier for Cardano Preview (testnet). + */ +export const CARDANO_PREVIEW_CAIP2 = "cardano:preview"; + +/** + * All Cardano networks supported by this implementation. + */ +export const CARDANO_NETWORKS = [ + CARDANO_MAINNET_CAIP2, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, +] as const; + +/** + * Cardano network ID encoded inside transaction bodies. + * Mainnet = 1, every testnet = 0. + */ +export const CARDANO_NETWORK_ID_MAINNET = 1; +/** + * Cardano network ID encoded inside transaction bodies for testnets. + */ +export const CARDANO_NETWORK_ID_TESTNET = 0; + +/** + * The Exact scheme identifier (matches other mechanisms). + */ +export const SCHEME_EXACT = "exact"; + +/** + * USDM policy id on Cardano Mainnet. + */ +export const USDM_MAINNET_POLICY_ID = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad"; + +/** + * USDM policy id on Cardano Preprod. + */ +export const USDM_PREPROD_POLICY_ID = "16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde"; + +/** + * USDM (333) reference asset name (hex of "(333) USDM"). + * + * The CIP-68 reference token uses the (333) prefix `0014df10` followed by the + * hex-encoded UTF-8 of `USDM` (0x5553444d). + */ +export const USDM_ASSET_NAME_HEX = "0014df105553444d"; + +/** + * Default USDM unit on Mainnet (`policyId.assetNameHex`). + */ +export const USDM_MAINNET_ASSET = `${USDM_MAINNET_POLICY_ID}.${USDM_ASSET_NAME_HEX}`; + +/** + * Default USDM unit on Preprod (`policyId.assetNameHex`). + */ +export const USDM_PREPROD_ASSET = `${USDM_PREPROD_POLICY_ID}.${USDM_ASSET_NAME_HEX}`; + +/** + * Default decimals for USDM (matches USDC). + */ +export const USDM_DEFAULT_DECIMALS = 6; + +/** + * Asset identifier for native ADA. The Cardano facilitator special-cases + * this value: lovelace lives in an output's `coin` field, not in its + * multi-asset map, so the verifier compares against `output.coin` when the + * asset string is exactly `"lovelace"`. + */ +export const LOVELACE_ASSET = "lovelace"; + +/** + * Cardano asset unit regex. + * + * Accepts either the literal `"lovelace"` (native ADA) or a + * `policyId.assetNameHex` pair, where: + * - policyId: 28 bytes -> 56 hex characters. + * - assetName: 0..32 bytes -> 0..64 hex characters. + */ +export const CARDANO_ASSET_REGEX = /^(lovelace|[0-9a-fA-F]{56}\.[0-9a-fA-F]{0,64})$/; + +/** + * Cardano payment address regex (very permissive). + * - Mainnet bech32: `addr1...` + * - Testnet bech32: `addr_test1...` + */ +export const CARDANO_ADDRESS_REGEX = /^(addr1|addr_test1)[0-9a-z]+$/; + +/** + * UTXO reference regex: `${txHashHex}#${index}`. + * - txHash: 32 bytes -> 64 hex characters. + * - index: non-negative integer. + */ +export const CARDANO_UTXO_REF_REGEX = /^[0-9a-fA-F]{64}#\d+$/; + +/** + * Maximum allowed value for assetTransferMethod. + */ +export const ASSET_TRANSFER_METHOD_DEFAULT = "default"; +/** + * Marker for the Masumi smart-contract assetTransferMethod. + */ +export const ASSET_TRANSFER_METHOD_MASUMI = "masumi"; +/** + * Marker for the script assetTransferMethod. + */ +export const ASSET_TRANSFER_METHOD_SCRIPT = "script"; + +/** + * Resolves the Cardano network ID embedded in the transaction body for a given + * x402 network identifier. + * + * @param network - The x402 network identifier (e.g. "cardano:mainnet"). + * @returns The Cardano network ID (1 = mainnet, 0 = testnet). + */ +export function getCardanoNetworkId(network: string): number { + switch (network) { + case CARDANO_MAINNET_CAIP2: + return CARDANO_NETWORK_ID_MAINNET; + case CARDANO_PREPROD_CAIP2: + case CARDANO_PREVIEW_CAIP2: + return CARDANO_NETWORK_ID_TESTNET; + default: + throw new Error(`Unsupported Cardano network: ${network}`); + } +} + +/** + * Returns true when the supplied network identifier is one of the Cardano + * networks supported by this mechanism. + * + * @param network - The network identifier to validate. + * @returns True if the network is a supported Cardano network. + */ +export function isCardanoNetwork(network: string): boolean { + return (CARDANO_NETWORKS as readonly string[]).includes(network); +} + +/** + * Returns the default USDM asset unit for the requested network. + * + * Preview is intentionally not covered by an officially deployed USDM token at + * the time of writing; callers must supply a custom asset for Preview + * deployments. + * + * @param network - The Cardano network identifier. + * @returns The default USDM asset unit string. + */ +export function getDefaultUsdmAsset(network: string): string { + switch (network) { + case CARDANO_MAINNET_CAIP2: + return USDM_MAINNET_ASSET; + case CARDANO_PREPROD_CAIP2: + return USDM_PREPROD_ASSET; + default: + throw new Error(`No default USDM asset configured for network: ${network}`); + } +} + +/** + * Error codes produced by the Cardano facilitator. Mirrors SVM/Aptos style for + * easy log filtering. + */ +export const ERR_UNSUPPORTED_SCHEME = "unsupported_scheme"; +/** Error: payload is missing required fields. */ +export const ERR_INVALID_PAYLOAD = "invalid_exact_cardano_payload"; +/** Error: declared and accepted networks differ. */ +export const ERR_NETWORK_MISMATCH = "network_mismatch"; +/** Error: signed transaction could not be CBOR decoded. */ +export const ERR_TRANSACTION_DECODE_FAILED = + "invalid_exact_cardano_payload_transaction_decode_failed"; +/** Error: transaction targets a different Cardano network than required. */ +export const ERR_NETWORK_ID_MISMATCH = "invalid_exact_cardano_payload_network_id_mismatch"; +/** Error: transaction has no output going to the requirements.payTo address. */ +export const ERR_RECIPIENT_MISMATCH = "invalid_exact_cardano_payload_recipient_mismatch"; +/** Error: matching output exists but pays a different asset. */ +export const ERR_ASSET_MISMATCH = "invalid_exact_cardano_payload_asset_mismatch"; +/** Error: matching output pays the right asset but not enough of it. */ +export const ERR_AMOUNT_INSUFFICIENT = "invalid_exact_cardano_payload_amount_insufficient"; +/** Error: nonce UTXO reference is missing or malformed. */ +export const ERR_NONCE_INVALID = "invalid_exact_cardano_payload_nonce_invalid"; +/** Error: nonce UTXO is not present as one of the transaction inputs. */ +export const ERR_NONCE_NOT_IN_INPUTS = "invalid_exact_cardano_payload_nonce_not_in_inputs"; +/** Error: nonce UTXO no longer exists on chain (already spent or never existed). */ +export const ERR_NONCE_NOT_ON_CHAIN = "invalid_exact_cardano_payload_nonce_not_on_chain"; +/** Error: transaction TTL has already passed. */ +export const ERR_TTL_EXPIRED = "invalid_exact_cardano_payload_ttl_expired"; +/** Error: transaction's lower validity bound is in the future. */ +export const ERR_VALIDITY_NOT_YET_VALID = "invalid_exact_cardano_payload_not_yet_valid"; +/** Error: facilitator could not perform an on-chain lookup needed for verification. */ +export const ERR_CHAIN_LOOKUP_FAILED = "exact_cardano_facilitator_chain_lookup_failed"; +/** Error: settlement failed when submitting the transaction. */ +export const ERR_SETTLEMENT_FAILED = "exact_cardano_settlement_failed"; +/** Error: facilitator declined a `mempool`-only settlement and `acceptMempool` is disabled. */ +export const ERR_SETTLEMENT_NOT_CONFIRMED = "exact_cardano_settlement_not_confirmed"; +/** Error: duplicate settlement detected within the cache window. */ +export const ERR_DUPLICATE_SETTLEMENT = "duplicate_settlement"; +/** Error: the script assetTransferMethod was selected but reconstruction failed. */ +export const ERR_SCRIPT_ADDRESS_MISMATCH = "invalid_exact_cardano_payload_script_address_mismatch"; +/** Error: required Cardano SDK is not installed. */ +export const ERR_CARDANO_SDK_MISSING = "exact_cardano_sdk_missing"; +/** Error: transaction is not signed (no vkey/bootstrap witnesses present). */ +export const ERR_TRANSACTION_UNSIGNED = "invalid_exact_cardano_payload_unsigned"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/client/index.ts b/typescript/packages/mechanisms/cardano/src/exact/client/index.ts new file mode 100644 index 0000000000..93aa7bb56d --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/client/index.ts @@ -0,0 +1 @@ +export { ExactCardanoScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts b/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts new file mode 100644 index 0000000000..f5e5615d1f --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts @@ -0,0 +1,103 @@ +import type { + PaymentPayload, + PaymentPayloadResult, + PaymentRequirements, + SchemeNetworkClient, +} from "@x402/core/types"; +import { + CARDANO_ADDRESS_REGEX, + CARDANO_ASSET_REGEX, + CARDANO_UTXO_REF_REGEX, + isCardanoNetwork, + SCHEME_EXACT, +} from "../../constants"; +import type { ClientCardanoConfig, ClientCardanoSigner } from "../../signer"; +import type { ExactCardanoPayload } from "../../types"; + +/** + * Cardano client implementation for the Exact payment scheme. + * + * The signer is responsible for choosing a UTXO that backs the payment and + * including it as both an input and as the `nonce` field returned alongside + * the signed transaction. + */ +export class ExactCardanoScheme implements SchemeNetworkClient { + readonly scheme = SCHEME_EXACT; + + /** + * Creates a new Cardano client scheme. + * + * @param signer - The Cardano client signer. + * @param _config - Optional client configuration (reserved for future use). + */ + constructor( + private readonly signer: ClientCardanoSigner, + private readonly _config?: ClientCardanoConfig, + ) { + void this._config; + } + + /** + * Builds a Cardano payment payload by delegating signing to the configured + * signer. The signer is responsible for honoring the assetTransferMethod in + * `paymentRequirements.extra`. + * + * @param x402Version - The x402 protocol version. + * @param paymentRequirements - The payment requirements to fulfill. + * @returns A promise resolving to the Cardano payment payload. + */ + async createPaymentPayload( + x402Version: number, + paymentRequirements: PaymentRequirements, + ): Promise { + if (!isCardanoNetwork(paymentRequirements.network)) { + throw new Error(`Unsupported Cardano network: ${paymentRequirements.network}`); + } + if (!paymentRequirements.payTo) { + throw new Error("Pay-to address is required"); + } + if (!CARDANO_ADDRESS_REGEX.test(paymentRequirements.payTo)) { + throw new Error(`Invalid Cardano pay-to address: ${paymentRequirements.payTo}`); + } + if (!paymentRequirements.asset) { + throw new Error("Asset is required"); + } + if (!CARDANO_ASSET_REGEX.test(paymentRequirements.asset)) { + throw new Error(`Invalid Cardano asset unit: ${paymentRequirements.asset}`); + } + if (!paymentRequirements.amount) { + throw new Error("Amount is required"); + } + if (!/^[0-9]+$/.test(paymentRequirements.amount)) { + throw new Error(`Amount must be a non-negative integer, got: ${paymentRequirements.amount}`); + } + + const result = await this.signer.signPaymentTransaction({ + network: paymentRequirements.network, + payTo: paymentRequirements.payTo, + asset: paymentRequirements.asset, + amount: paymentRequirements.amount, + maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds, + extra: paymentRequirements.extra, + }); + + if (!result || typeof result.transaction !== "string" || result.transaction.length === 0) { + throw new Error("Cardano signer returned an empty transaction"); + } + if (!result.nonce || !CARDANO_UTXO_REF_REGEX.test(result.nonce)) { + throw new Error(`Cardano signer returned an invalid nonce: ${result.nonce}`); + } + + const payload: ExactCardanoPayload = { + transaction: result.transaction, + nonce: result.nonce, + }; + + void (null as unknown as PaymentPayload); + + return { + x402Version, + payload, + }; + } +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts b/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts new file mode 100644 index 0000000000..7399aaa69a --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts @@ -0,0 +1,2 @@ +export { ExactCardanoScheme, supportedCardanoNetworks } from "./scheme"; +export type { ExactCardanoFacilitatorConfig } from "./scheme"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts new file mode 100644 index 0000000000..1a324d449d --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts @@ -0,0 +1,506 @@ +import type { + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { + ASSET_TRANSFER_METHOD_DEFAULT, + ASSET_TRANSFER_METHOD_MASUMI, + ASSET_TRANSFER_METHOD_SCRIPT, + CARDANO_NETWORKS, + ERR_AMOUNT_INSUFFICIENT, + ERR_ASSET_MISMATCH, + ERR_CARDANO_SDK_MISSING, + ERR_CHAIN_LOOKUP_FAILED, + ERR_DUPLICATE_SETTLEMENT, + ERR_INVALID_PAYLOAD, + ERR_NETWORK_ID_MISMATCH, + ERR_NETWORK_MISMATCH, + ERR_NONCE_INVALID, + ERR_NONCE_NOT_IN_INPUTS, + ERR_NONCE_NOT_ON_CHAIN, + ERR_RECIPIENT_MISMATCH, + ERR_SCRIPT_ADDRESS_MISMATCH, + ERR_SETTLEMENT_FAILED, + ERR_SETTLEMENT_NOT_CONFIRMED, + ERR_TRANSACTION_DECODE_FAILED, + ERR_TRANSACTION_UNSIGNED, + ERR_TTL_EXPIRED, + ERR_UNSUPPORTED_SCHEME, + ERR_VALIDITY_NOT_YET_VALID, + getCardanoNetworkId, + isCardanoNetwork, + SCHEME_EXACT, +} from "../../constants"; +import type { + CardanoExtra, + CardanoExtraScript, + DecodedCardanoTransaction, + ExactCardanoPayload, +} from "../../types"; +import type { FacilitatorCardanoSigner } from "../../signer"; +import { decodeCardanoPayload, decodeCardanoTransaction, parseUtxoRef } from "../../utils"; + +/** + * Optional configuration knobs for the Cardano facilitator scheme. + */ +export interface ExactCardanoFacilitatorConfig { + /** + * Time-to-live (in milliseconds) for the duplicate-settlement cache. Defaults + * to two minutes which exceeds typical Cardano slot/block lifetimes. + */ + duplicateCacheTtlMs?: number; + /** + * If `true` the facilitator returns `status: "mempool"` even when the signer + * confirms inclusion. Default is `false`. Even when enabled the spec + * strongly discourages granting access on mempool inclusion. + */ + acceptMempool?: boolean; +} + +/** + * Cardano facilitator implementation for the Exact payment scheme. + * + * Performs all checks listed in the "Facilitator Verification Rules" section + * of `specs/schemes/exact/scheme_exact_cardano.md` (rules 1-6) before + * accepting a payment. Settlement re-runs verification before submitting. + */ +export class ExactCardanoScheme implements SchemeNetworkFacilitator { + readonly scheme = SCHEME_EXACT; + readonly caipFamily = "cardano:*"; + + private readonly settlementCache = new Map(); + private readonly duplicateCacheTtlMs: number; + private readonly acceptMempool: boolean; + + /** + * Creates a new Cardano facilitator scheme. + * + * @param signer - The facilitator signer / chain query implementation. + * @param config - Optional configuration knobs. + */ + constructor( + private readonly signer: FacilitatorCardanoSigner, + config: ExactCardanoFacilitatorConfig = {}, + ) { + this.duplicateCacheTtlMs = config.duplicateCacheTtlMs ?? 120_000; + this.acceptMempool = config.acceptMempool ?? false; + } + + /** + * Returns extra metadata for the `/supported` endpoint. Cardano payments do + * not require server-side metadata in `default` mode, but consumers may + * extend this method in subclasses. + * + * @param _network - The Cardano network identifier (unused). + * @returns Always `undefined` for the default Cardano scheme. + */ + getExtra(_network: string): Record | undefined { + void _network; + return undefined; + } + + /** + * Returns the addresses managed by this facilitator for the supplied + * network. Used by the `/supported` response. + * + * @param _network - The Cardano network identifier. + * @returns The list of facilitator addresses. + */ + getSigners(_network: string): string[] { + void _network; + return [...this.signer.getAddresses()]; + } + + /** + * Verifies a Cardano payment against the supplied requirements following + * the spec's six rules. + * + * @param payload - The Cardano payment payload. + * @param requirements - The payment requirements being fulfilled. + * @returns A verify response describing success or failure. + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + try { + if (payload.x402Version !== 2) { + return { + isValid: false, + invalidReason: `${ERR_INVALID_PAYLOAD}_unsupported_version`, + payer: "", + }; + } + + if (payload.accepted.scheme !== SCHEME_EXACT || requirements.scheme !== SCHEME_EXACT) { + return { isValid: false, invalidReason: ERR_UNSUPPORTED_SCHEME, payer: "" }; + } + + if (payload.accepted.network !== requirements.network) { + return { isValid: false, invalidReason: ERR_NETWORK_MISMATCH, payer: "" }; + } + + if (!isCardanoNetwork(requirements.network)) { + return { isValid: false, invalidReason: ERR_NETWORK_MISMATCH, payer: "" }; + } + + let cardanoPayload: ExactCardanoPayload; + try { + cardanoPayload = decodeCardanoPayload(payload.payload as Record); + } catch { + return { isValid: false, invalidReason: ERR_INVALID_PAYLOAD, payer: "" }; + } + + let parsedNonce: { txHash: string; index: number }; + try { + parsedNonce = parseUtxoRef(cardanoPayload.nonce); + } catch { + return { isValid: false, invalidReason: ERR_NONCE_INVALID, payer: "" }; + } + + let decoded: DecodedCardanoTransaction; + try { + decoded = await decodeCardanoTransaction(cardanoPayload.transaction); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + // A missing peer dep is a facilitator misconfiguration, not client error. + // Bubble up as a chain-lookup-style failure with a descriptive message so + // operators can fix the install, instead of marking valid payments as + // malformed CBOR. + if (message.startsWith(ERR_CARDANO_SDK_MISSING)) { + return { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: message, + payer: "", + }; + } + return { + isValid: false, + invalidReason: ERR_TRANSACTION_DECODE_FAILED, + invalidMessage: message, + payer: "", + }; + } + + // Rule 1: network validation. When the body declares a network_id it MUST + // match the declared network. Absence of network_id is permitted: the + // field is optional in the Cardano CBOR spec and many wallets omit it. + // Network correctness is still enforced by Rule 3 (payTo address check): + // Cardano addresses are network-tagged (addr_test1... vs addr1...), so a + // testnet address cannot be submitted on mainnet and vice versa. + const expectedNetworkId = getCardanoNetworkId(requirements.network); + if (decoded.networkId !== undefined && decoded.networkId !== expectedNetworkId) { + return { + isValid: false, + invalidReason: ERR_NETWORK_ID_MISMATCH, + payer: "", + }; + } + + // SECURITY: refuse unsigned transactions in verify() so /verify cannot + // return a false-positive that would let callers grant access on an + // unpaid request. The witness-count check is a fast structural guard; + // signers SHOULD also implement `evaluateTransaction` (called below) + // for full cryptographic authorization checks via a Cardano node + // dry-run. + if (decoded.vkeyWitnessCount === 0 && decoded.scriptWitnessCount === 0) { + return { isValid: false, invalidReason: ERR_TRANSACTION_UNSIGNED, payer: "" }; + } + + // Rule 6 (TTL upper bound) AND lower validity bound: when either is + // declared, fetch the current slot once and check both. The spec only + // calls out TTL explicitly, but a transaction whose lower bound is in + // the future is also not yet valid; without this check verify() could + // return isValid:true even though the chain would refuse the tx. + if (decoded.ttlSlot !== undefined || decoded.validityStartSlot !== undefined) { + let currentSlot: bigint; + try { + currentSlot = await this.signer.getCurrentSlot(requirements.network); + } catch (cause) { + return { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }; + } + if (decoded.ttlSlot !== undefined && decoded.ttlSlot <= currentSlot) { + return { isValid: false, invalidReason: ERR_TTL_EXPIRED, payer: "" }; + } + if (decoded.validityStartSlot !== undefined && decoded.validityStartSlot > currentSlot) { + return { isValid: false, invalidReason: ERR_VALIDITY_NOT_YET_VALID, payer: "" }; + } + } + + // Rule 5 (input check): nonce UTXO MUST appear as an input. + const inputSet = new Set(decoded.inputs.map(i => i.toLowerCase())); + const nonceLower = `${parsedNonce.txHash.toLowerCase()}#${parsedNonce.index}`; + if (!inputSet.has(nonceLower)) { + return { isValid: false, invalidReason: ERR_NONCE_NOT_IN_INPUTS, payer: "" }; + } + + // Rule 5 (chain check): nonce UTXO MUST currently be unspent. + let nonceSnapshot; + try { + nonceSnapshot = await this.signer.getUtxo(nonceLower, requirements.network); + } catch (cause) { + return { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }; + } + if (!nonceSnapshot.exists) { + return { isValid: false, invalidReason: ERR_NONCE_NOT_ON_CHAIN, payer: "" }; + } + + const payer = nonceSnapshot.address ?? ""; + + // Rules 2, 3, 4: at least one output MUST pay the requested amount of + // the requested asset to the requested address. Lovelace is special- + // cased because native ADA lives in `output.coin` rather than the + // multi-asset map. + const requestedAmount = BigInt(requirements.amount); + const assetKey = requirements.asset.toLowerCase(); + const isLovelace = assetKey === "lovelace"; + let recipientFound = false; + let assetFoundForRecipient = false; + let bestAvailable = 0n; + + for (const output of decoded.outputs) { + if (output.address !== requirements.payTo) { + continue; + } + recipientFound = true; + const available = isLovelace ? output.coin : output.assets[assetKey]; + if (available === undefined) { + continue; + } + assetFoundForRecipient = true; + if (available > bestAvailable) bestAvailable = available; + if (available >= requestedAmount) { + // SECURITY: Read assetTransferMethod from the canonical + // server-supplied requirements, NOT from payload.accepted.extra + // (which is client-echoed and could lie about the method to + // bypass script-mode reconstruction checks). + const methodCheck = await this.runMethodSpecificChecks( + requirements.extra, + requirements.payTo, + payer, + ); + if (!methodCheck.ok) { + return { isValid: false, invalidReason: methodCheck.reason, payer }; + } + // Optional final guard: full cryptographic authorization check via a + // Cardano node dry-run. Skipped when the signer does not implement + // `evaluateTransaction`; in that case verify() is best-effort and + // settle() will still surface an invalid-signature error at submit. + if (typeof this.signer.evaluateTransaction === "function") { + try { + await this.signer.evaluateTransaction( + cardanoPayload.transaction, + requirements.network, + ); + } catch (cause) { + return { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer, + }; + } + } + return { isValid: true, payer }; + } + } + + if (!recipientFound) { + return { isValid: false, invalidReason: ERR_RECIPIENT_MISMATCH, payer }; + } + if (!assetFoundForRecipient) { + return { isValid: false, invalidReason: ERR_ASSET_MISMATCH, payer }; + } + void bestAvailable; + return { isValid: false, invalidReason: ERR_AMOUNT_INSUFFICIENT, payer }; + } catch (error) { + return { + isValid: false, + invalidReason: `${ERR_INVALID_PAYLOAD}_verification_error`, + invalidMessage: error instanceof Error ? error.message : String(error), + payer: "", + }; + } + } + + /** + * Settles a Cardano payment by re-verifying and submitting the transaction. + * + * @param payload - The Cardano payment payload. + * @param requirements - The payment requirements. + * @returns A settle response describing success or failure. + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + const verifyResult = await this.verify(payload, requirements); + if (!verifyResult.isValid) { + return { + success: false, + errorReason: verifyResult.invalidReason ?? "verification_failed", + transaction: "", + network: payload.accepted.network, + }; + } + + const cardanoPayload = decodeCardanoPayload(payload.payload as Record); + const cacheKey = cardanoPayload.transaction; + // Atomically claim the cache key so concurrent settle() calls do not all + // pass the duplicate check before the first await on submitTransaction(). + if (!this.tryClaim(cacheKey)) { + return { + success: false, + errorReason: ERR_DUPLICATE_SETTLEMENT, + transaction: "", + network: payload.accepted.network, + }; + } + + try { + const submission = await this.signer.submitTransaction( + cardanoPayload.transaction, + requirements.network, + ); + + // Honor `acceptMempool`: when the signer reports only mempool inclusion + // and the operator has not opted in, refuse to call the payment settled. + // We keep the claim in place even on rejection so retries cannot + // rebroadcast the same transaction repeatedly. + if (submission.status !== "confirmed" && !this.acceptMempool) { + return { + success: false, + errorReason: ERR_SETTLEMENT_NOT_CONFIRMED, + transaction: submission.txHash, + network: payload.accepted.network, + extensions: { status: submission.status }, + }; + } + + return { + success: true, + transaction: submission.txHash, + network: payload.accepted.network, + extensions: { status: submission.status }, + }; + } catch { + // Submission threw (network error, deserialization error, etc.). Free + // the claim so the caller can retry with a corrected payload. + this.releaseClaim(cacheKey); + return { + success: false, + errorReason: ERR_SETTLEMENT_FAILED, + transaction: "", + network: payload.accepted.network, + }; + } + } + + /** + * Runs the verification step that depends on the assetTransferMethod + * declared in `requirements.extra`. + * + * - `default` / undefined: no extra verification beyond the asset+amount+ + * address checks performed by the caller. + * - `masumi`: the spec does not require additional on-chain verification + * beyond the transfer itself; integrators wishing to enforce extra Masumi + * invariants can subclass and override this method. + * - `script`: the facilitator MUST reconstruct the script address from the + * supplied script + parameters and confirm it equals `requirements.payTo`. + * The base class cannot do this without a Cardano SDK that understands + * the user's parameter encoding, so we REJECT script payments unless an + * override has supplied the reconstruction logic. + * + * @param extra - The accepted requirements' extra block. + * @param payTo - The recipient address declared in the payment requirements. + * @param payer - The payer address (passed through for context). + * @returns Result describing success or a precise failure reason. + */ + protected async runMethodSpecificChecks( + extra: Record | undefined, + payTo: string, + payer: string, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + void payer; + void payTo; + const method = + (extra as CardanoExtra | undefined)?.assetTransferMethod ?? ASSET_TRANSFER_METHOD_DEFAULT; + if (method === ASSET_TRANSFER_METHOD_DEFAULT || method === ASSET_TRANSFER_METHOD_MASUMI) { + return { ok: true }; + } + if (method === ASSET_TRANSFER_METHOD_SCRIPT) { + const scriptExtra = extra as CardanoExtraScript; + if (!scriptExtra.scriptHash && !scriptExtra.script) { + return { ok: false, reason: ERR_SCRIPT_ADDRESS_MISMATCH }; + } + // SECURITY: per the spec, the facilitator must verify that applying the + // declared script + parameters yields exactly `payTo`. The base class + // cannot do this without an opinionated SDK, so we reject by default + // and require integrators to override this method. + return { ok: false, reason: ERR_SCRIPT_ADDRESS_MISMATCH }; + } + return { ok: false, reason: ERR_UNSUPPORTED_SCHEME }; + } + + /** + * + * @param key + */ + /** + * Atomically claim a cache key for an in-flight or completed settlement. + * Synchronous so concurrent settle() calls cannot all race past the check. + * + * @param key - Cache key, typically the base64-encoded transaction. + * @returns True when the claim was acquired; false when the key was already + * claimed within the duplicate-settlement window. + */ + private tryClaim(key: string): boolean { + const seenAt = this.settlementCache.get(key); + const now = Date.now(); + if (seenAt !== undefined && now - seenAt <= this.duplicateCacheTtlMs) { + return false; + } + this.settlementCache.set(key, now); + if (this.settlementCache.size > 1024) { + const cutoff = now - this.duplicateCacheTtlMs; + for (const [k, t] of this.settlementCache) { + if (t < cutoff) this.settlementCache.delete(k); + } + } + return true; + } + + /** + * Releases a previously-claimed cache key so retries can attempt + * settlement again. Called when submission throws a transient error. + * + * @param key - Cache key, typically the base64-encoded transaction. + * @returns Nothing. + */ + private releaseClaim(key: string): void { + this.settlementCache.delete(key); + } +} + +/** + * Convenience helper exposing the list of networks this scheme supports. + * + * @returns The supported Cardano CAIP-style network identifiers. + */ +export function supportedCardanoNetworks(): readonly string[] { + return CARDANO_NETWORKS; +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/index.ts b/typescript/packages/mechanisms/cardano/src/exact/index.ts new file mode 100644 index 0000000000..276aa9ee5b --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/index.ts @@ -0,0 +1,2 @@ +// Default re-export points at the client scheme to mirror Aptos/SVM conventions. +export { ExactCardanoScheme } from "./client/scheme"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/server/index.ts b/typescript/packages/mechanisms/cardano/src/exact/server/index.ts new file mode 100644 index 0000000000..93aa7bb56d --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/server/index.ts @@ -0,0 +1 @@ +export { ExactCardanoScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts b/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts new file mode 100644 index 0000000000..e2646284e2 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts @@ -0,0 +1,176 @@ +import type { + AssetAmount, + Money, + MoneyParser, + Network, + PaymentRequirements, + Price, + SchemeNetworkServer, +} from "@x402/core/types"; +import { + CARDANO_ASSET_REGEX, + getDefaultUsdmAsset, + isCardanoNetwork, + SCHEME_EXACT, + USDM_DEFAULT_DECIMALS, +} from "../../constants"; + +/** + * Cardano server-side implementation for the Exact scheme. + * + * Performs Money-to-AssetAmount parsing using a registerable parser chain and + * leaves Cardano-specific extra fields untouched, since most extras are + * server-supplied (assetTransferMethod, Masumi metadata, script descriptors). + */ +export class ExactCardanoScheme implements SchemeNetworkServer { + readonly scheme = SCHEME_EXACT; + private readonly moneyParsers: MoneyParser[] = []; + + /** + * Registers a custom Money parser. Parsers are tried in registration order; + * the first non-null result wins. Returns `null` to defer to the next + * parser. + * + * @param parser - The parser to register. + * @returns This instance for chaining. + */ + registerMoneyParser(parser: MoneyParser): ExactCardanoScheme { + this.moneyParsers.push(parser); + return this; + } + + /** + * Converts a price into an AssetAmount. AssetAmount inputs are passed + * through (after asset validation); Money inputs are parsed via the parser + * chain falling back to the default USDM conversion. + * + * @param price - The price to parse. + * @param network - The Cardano network identifier. + * @returns The resolved AssetAmount. + */ + async parsePrice(price: Price, network: Network): Promise { + if (typeof price === "object" && price !== null && "amount" in price) { + if (!price.asset) { + throw new Error(`Asset unit must be specified for AssetAmount on network ${network}`); + } + if (!CARDANO_ASSET_REGEX.test(price.asset)) { + throw new Error(`Invalid Cardano asset unit: ${price.asset}`); + } + return { amount: price.amount, asset: price.asset, extra: price.extra ?? {} }; + } + + const decimal = this.parseMoneyToDecimal(price as Money); + for (const parser of this.moneyParsers) { + const result = await parser(decimal, network); + if (result !== null) { + if (!CARDANO_ASSET_REGEX.test(result.asset)) { + throw new Error(`Custom money parser returned invalid Cardano asset: ${result.asset}`); + } + return result; + } + } + return this.defaultMoneyConversion(decimal, network); + } + + /** + * Returns the decimal precision for the supplied asset. Currently we assume + * USDM/USDC decimals (6) when the asset matches the default; integrators + * should subclass to provide custom decimals for other tokens. + * + * @param _asset - The asset unit string. + * @param _network - The Cardano network identifier. + * @returns Decimal precision. + */ + getAssetDecimals(_asset: string, _network: Network): number { + void _asset; + void _network; + return USDM_DEFAULT_DECIMALS; + } + + /** + * Enhances payment requirements before they are returned to the client. + * The base implementation passes the supportedKind extra through so any + * facilitator-supplied metadata reaches the client. + * + * @param paymentRequirements - The base payment requirements. + * @param supportedKind - The matching SupportedKind. + * @param supportedKind.x402Version - The x402 version (unused). + * @param supportedKind.scheme - The scheme name (unused). + * @param supportedKind.network - The network identifier. + * @param supportedKind.extra - Optional facilitator extra metadata. + * @param extensionKeys - The list of facilitator extension keys. + * @returns Promise resolving to enhanced payment requirements. + */ + enhancePaymentRequirements( + paymentRequirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + extensionKeys: string[], + ): Promise { + void extensionKeys; + if (!isCardanoNetwork(supportedKind.network)) { + throw new Error(`Unsupported Cardano network: ${supportedKind.network}`); + } + const extra: Record = { + ...(supportedKind.extra ?? {}), + ...(paymentRequirements.extra ?? {}), + }; + return Promise.resolve({ ...paymentRequirements, extra }); + } + + /** + * Parses a Money value (string with optional `$`/USD suffix or number) into a + * decimal number. + * + * @param money - The Money value to parse. + * @returns The decimal value as a number. + */ + private parseMoneyToDecimal(money: Money): number { + if (typeof money === "number") { + return money; + } + const cleaned = money + .replace(/^\$/, "") + .replace(/\s*(USD|USDC|USDM)\s*$/i, "") + .trim(); + const value = parseFloat(cleaned); + if (isNaN(value)) { + throw new Error(`Invalid money format: ${money}`); + } + return value; + } + + /** + * Falls back to converting a Money decimal to atomic units on the given + * network. Honors `getAssetDecimals()` so subclasses that override the + * hook for non-USDM tokens get correctly scaled atomic amounts. + * + * @param amount - The decimal amount. + * @param network - The Cardano network identifier. + * @returns The resulting AssetAmount. + */ + private defaultMoneyConversion(amount: number, network: Network): AssetAmount { + const asset = getDefaultUsdmAsset(network); + const decimals = this.getAssetDecimals(asset, network); + const tokenAmount = this.toAtomic(amount, decimals); + return { amount: tokenAmount, asset, extra: {} }; + } + + /** + * Converts a decimal amount into atomic units using the given decimal + * precision (e.g. 6 for USDM/USDC). + * + * @param amount - The decimal amount. + * @param decimals - The token decimal precision. + * @returns The amount in atomic units as a base-10 string. + */ + private toAtomic(amount: number, decimals: number): string { + const [whole, fractional = ""] = amount.toString().split("."); + const padded = fractional.padEnd(decimals, "0").slice(0, decimals); + return BigInt(whole + padded).toString(); + } +} diff --git a/typescript/packages/mechanisms/cardano/src/index.ts b/typescript/packages/mechanisms/cardano/src/index.ts new file mode 100644 index 0000000000..4884c29065 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/index.ts @@ -0,0 +1,14 @@ +// Exact scheme exports +export * from "./exact"; + +// Types +export * from "./types"; + +// Constants +export * from "./constants"; + +// Signer protocols +export * from "./signer"; + +// Utils +export * from "./utils"; diff --git a/typescript/packages/mechanisms/cardano/src/signer.ts b/typescript/packages/mechanisms/cardano/src/signer.ts new file mode 100644 index 0000000000..3b3a51283b --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/signer.ts @@ -0,0 +1,201 @@ +/** + * Configuration for the client-side signer. + */ +export interface ClientCardanoConfig { + /** + * Optional custom RPC / chain query URL used by the client (e.g. a Blockfrost + * or Koios endpoint). + */ + rpcUrl?: string; +} + +/** + * Client-side signer protocol for Cardano. + * + * Implementations integrate the user's wallet / key management. The signer + * receives the desired payment requirements and returns a base64-encoded + * signed Cardano transaction along with the UTXO reference used as nonce. + */ +export interface ClientCardanoSigner { + /** + * Returns the bech32 address that will fund the payment. + * + * @returns The bech32 payment address. + */ + getAddress(): string; + + /** + * Builds and signs a Cardano transaction satisfying the supplied payment + * requirements. The implementation MUST return both the signed CBOR + * transaction (base64) and the UTXO reference it consumed for replay + * protection. The chosen UTXO MUST appear as a transaction input. + * + * @param input - Payment building parameters. + * @returns A promise resolving to the signed transaction and nonce. + */ + signPaymentTransaction( + input: ClientCardanoSignInput, + ): Promise | ClientCardanoSignResult; +} + +/** + * Inputs forwarded to a client signer when constructing a payment. + */ +export interface ClientCardanoSignInput { + /** + * The x402 network identifier (e.g. "cardano:mainnet"). + */ + network: string; + /** + * The recipient bech32 address. + */ + payTo: string; + /** + * The asset unit (`policyId.assetNameHex`). + */ + asset: string; + /** + * The amount in the asset's smallest unit, as a string. + */ + amount: string; + /** + * Maximum lifetime of the transaction in seconds. + */ + maxTimeoutSeconds: number; + /** + * The full `extra` block coming from the payment requirements (includes + * assetTransferMethod and any method-specific metadata). + */ + extra?: Record; +} + +/** + * Result returned by a client signer. + */ +export interface ClientCardanoSignResult { + /** + * Base64 encoded signed Cardano transaction (CBOR). + */ + transaction: string; + /** + * UTXO reference (`txHashHex#index`) used as nonce. MUST appear as a tx input. + */ + nonce: string; +} + +/** + * Status returned by the chain layer for a settled / submitted transaction. + */ +export type CardanoSettlementStatus = "confirmed" | "mempool"; + +/** + * Result of submitting a transaction via a facilitator signer. + */ +export interface CardanoSubmissionResult { + /** + * Hex transaction hash returned by the chain layer. + */ + txHash: string; + /** + * Settlement status as defined by the spec ("confirmed" recommended; + * "mempool" is permitted but strongly discouraged). + */ + status: CardanoSettlementStatus; +} + +/** + * Lightweight UTXO summary returned by the facilitator's chain query layer. + */ +export interface CardanoUtxoSnapshot { + /** + * Whether the UTXO currently exists in the chain's UTXO set (i.e. is unspent). + */ + exists: boolean; + /** + * Optional bech32 address that controls the UTXO. Useful for diagnostics. + */ + address?: string; +} + +/** + * Facilitator-side signer / chain-query protocol for Cardano. + * + * Verification rule 5 of the spec requires confirming that the nonce UTXO + * exists in the on-chain UTXO set. Verification rule 6 needs the current slot + * to compare against the transaction's TTL. Settlement (step 6 of the + * protocol) needs to submit the transaction. All of these are abstracted + * behind this protocol so the mechanism remains agnostic to the specific + * Cardano chain provider (Blockfrost, Koios, Yaci-Store, Ogmios, etc.). + */ +export interface FacilitatorCardanoSigner { + /** + * Returns all addresses managed by this facilitator. Useful for producing + * the `signers` field of the `/supported` response. + * + * @returns An array of bech32 addresses. + */ + getAddresses(): readonly string[]; + + /** + * Looks up a single UTXO by reference. + * + * Implementations SHOULD return `{ exists: false }` when the UTXO has been + * spent or never existed, and rethrow / let exceptions propagate when the + * lookup itself fails (network error, unknown chain, …). + * + * @param ref - The UTXO reference (`txHashHex#index`). + * @param network - The x402 network identifier. + * @returns A snapshot describing the UTXO presence. + */ + getUtxo(ref: string, network: string): Promise; + + /** + * Returns the current absolute slot number for the supplied network. + * + * @param network - The x402 network identifier. + * @returns The current absolute slot. + */ + getCurrentSlot(network: string): Promise; + + /** + * Submits a fully signed transaction to the chain. Implementations MAY wait + * for confirmation; if they do not, they SHOULD return `status: "mempool"` + * and the facilitator will surface that to the client (the spec discourages + * granting access on `mempool`). + * + * @param signedTransactionBase64 - The base64-encoded CBOR transaction. + * @param network - The x402 network identifier. + * @returns The submission result. + */ + submitTransaction( + signedTransactionBase64: string, + network: string, + ): Promise; + + /** + * Optional: waits for confirmation of a previously submitted transaction. + * Implementations that already wait inside `submitTransaction` may return + * immediately. + * + * @param txHash - The hex transaction hash to wait for. + * @param network - The x402 network identifier. + * @returns A promise that resolves once the transaction is confirmed. + */ + waitForConfirmation?(txHash: string, network: string): Promise; + + /** + * Optional: ask a Cardano node / evaluation service to dry-run the signed + * transaction. When implemented, the facilitator's `verify()` calls it after + * the spec's six rules have passed; a successful dry-run proves the + * signatures actually authorize the consumed inputs (the witness-count + * check alone only proves witness material is present). + * + * Implementations should throw on any rejection. The thrown error is + * surfaced as `invalid_message` on the verify response. + * + * @param signedTransactionBase64 - The base64-encoded CBOR transaction. + * @param network - The x402 network identifier. + * @returns A promise that resolves on a successful dry-run. + */ + evaluateTransaction?(signedTransactionBase64: string, network: string): Promise; +} diff --git a/typescript/packages/mechanisms/cardano/src/types.ts b/typescript/packages/mechanisms/cardano/src/types.ts new file mode 100644 index 0000000000..d98ea1e0f7 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/types.ts @@ -0,0 +1,221 @@ +/** + * Payload structure carried inside a Cardano `exact` PaymentPayload. + * + * The `transaction` field is a base64-encoded, fully signed Cardano CBOR + * transaction. The `nonce` field is a UTXO reference (`txHashHex#index`) that + * MUST also appear as one of the transaction inputs. The facilitator uses the + * nonce to enforce uniqueness and replay protection (rule 5 in the spec). + */ +export type ExactCardanoPayload = { + /** + * Base64 encoded fully signed Cardano transaction (CBOR). + */ + transaction: string; + /** + * UTXO reference (`txHash#index`) used as nonce, must be present as a tx input. + */ + nonce: string; +}; + +/** + * Common (default) `extra` shape for Cardano payment requirements. + * + * The default assetTransferMethod is the address-to-address flow described in + * the spec — `extra` may be empty or carry caller-defined metadata. + */ +export interface CardanoExtraDefault { + /** + * Free-form metadata. Implementations MUST tolerate unknown keys. + */ + [key: string]: unknown; + /** + * Optional explicit method marker. Defaults to "default" when missing. + */ + assetTransferMethod?: "default"; +} + +/** + * `extra` shape for the Masumi assetTransferMethod (Masumi smart protocol). + * + * Field semantics follow the spec section "Masumi assetTransferMethod Schema". + */ +export interface CardanoExtraMasumi { + /** + * Free-form additional metadata. + */ + [key: string]: unknown; + /** + * Method marker selecting Masumi semantics. + */ + assetTransferMethod: "masumi"; + /** + * Identifier supplied by the purchaser (Masumi flow). + */ + identifierFromPurchaser: string; + /** + * Verification key of the seller (Masumi flow). + */ + sellerVkey: string; + /** + * Masumi payment type discriminator (e.g. "Web3CardanoV1"). + */ + paymentType: string; + /** + * Blockchain-side identifier for this purchase. + */ + blockchainIdentifier: string; + /** + * Unix timestamp (seconds) by which payment must be made. + */ + payByTime: string; + /** + * Unix timestamp (seconds) by which the seller must submit the result. + */ + submitResultTime: string; + /** + * Unix timestamp (seconds) at which funds unlock. + */ + unlockTime: string; + /** + * Unix timestamp (seconds) for the external dispute window. + */ + externalDisputeUnlockTime: string; + /** + * Identifier of the providing agent. + */ + agentIdentifier: string; + /** + * Hash of the input data (hex). + */ + inputHash: string; +} + +/** + * Plutus script descriptor used in the script assetTransferMethod. + */ +export interface CardanoScriptDescriptor { + /** + * The script type: only Plutus script types are valid. + */ + type: "plutusV1" | "plutusV2" | "plutusV3"; + /** + * Hex-encoded script bytes. + */ + code: string; +} + +/** + * One parameter applied to a Plutus script during transaction building. + */ +export interface CardanoScriptParameter { + /** + * The PlutusData primitive type. + */ + type: "bytes" | "bigint" | "integer" | "string" | "constr" | "list" | "map" | "boolean"; + /** + * The parameter value. Encoding is `type`-specific. + */ + value: unknown; +} + +/** + * `extra` shape for the script assetTransferMethod. + */ +export interface CardanoExtraScript { + /** + * Free-form additional metadata. + */ + [key: string]: unknown; + /** + * Method marker selecting script semantics. + */ + assetTransferMethod: "script"; + /** + * Hash of the script as published on-chain (optional if `script` is inlined). + */ + scriptHash?: string; + /** + * Inlined script body (optional if `scriptHash` is provided). + */ + script?: CardanoScriptDescriptor; + /** + * Parameters that are applied to the script during transaction building. + * Maps parameter name to its value descriptor. + */ + parameters?: Record; +} + +/** + * Discriminated union of every supported `extra` shape for Cardano. + */ +export type CardanoExtra = CardanoExtraDefault | CardanoExtraMasumi | CardanoExtraScript; + +/** + * Lightweight description of a UTXO output kept in flight memory so that the + * facilitator can perform input/asset checks without depending on a particular + * Cardano SDK type. + */ +export interface CardanoUtxoOutput { + /** + * The bech32 payment address that owns this UTXO. + */ + address: string; + /** + * Quantity of lovelace (ADA) attached to the UTXO. + */ + coin: bigint; + /** + * Map of `policyId.assetNameHex` -> quantity for native tokens. + */ + assets: Record; +} + +/** + * Decoded view of the relevant fields from a Cardano transaction body. + * + * Used by the facilitator's verifier so the heavy CBOR decoding lives in one + * place behind a stable shape. + */ +export interface DecodedCardanoTransaction { + /** + * Hex-encoded transaction hash (BLAKE2b-256 of the body). + */ + txHash: string; + /** + * Network ID embedded in the transaction body (1 = mainnet, 0 = testnet), + * or `undefined` if absent. + */ + networkId?: number; + /** + * TTL slot number, or `undefined` if no TTL is set. + */ + ttlSlot?: bigint; + /** + * `validityStart` slot number (lower bound), or `undefined` if absent. + */ + validityStartSlot?: bigint; + /** + * Transaction inputs as ordered UTXO references (`txHashHex#index`). + */ + inputs: string[]; + /** + * Decoded outputs in declaration order. + */ + outputs: CardanoUtxoOutput[]; + /** + * Number of vkey + bootstrap witnesses present in the transaction. Used by + * the facilitator to refuse unsigned transactions in `verify()`. + */ + vkeyWitnessCount: number; + /** + * Number of script witnesses (native + plutus) present. A script-mode + * payment must carry at least one redeemer; for default/Masumi payments + * either vkey or bootstrap witnesses suffice. + */ + scriptWitnessCount: number; + /** + * Index of the auxiliary data hash, if any (kept for parity with future + * additions; unused today). + */ + auxiliaryDataHash?: string; +} diff --git a/typescript/packages/mechanisms/cardano/src/utils.ts b/typescript/packages/mechanisms/cardano/src/utils.ts new file mode 100644 index 0000000000..99405368b5 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/utils.ts @@ -0,0 +1,389 @@ +import { CARDANO_ASSET_REGEX, CARDANO_UTXO_REF_REGEX, ERR_CARDANO_SDK_MISSING } from "./constants"; +import type { CardanoUtxoOutput, DecodedCardanoTransaction, ExactCardanoPayload } from "./types"; + +/** + * Splits a Cardano asset unit (`policyId.assetNameHex`) into its components. + * + * @param asset - The asset unit string. + * @returns The parsed policy id and asset name. + */ +export function parseAssetUnit(asset: string): { policyId: string; assetNameHex: string } { + if (!CARDANO_ASSET_REGEX.test(asset)) { + throw new Error(`Invalid Cardano asset unit: ${asset}`); + } + if (asset.toLowerCase() === "lovelace") { + return { policyId: "", assetNameHex: "" }; + } + const [policyId, assetNameHex] = asset.split("."); + return { policyId: policyId.toLowerCase(), assetNameHex: assetNameHex.toLowerCase() }; +} + +/** + * Parses a UTXO reference (`txHashHex#index`). + * + * @param ref - The UTXO reference. + * @returns The parsed transaction hash and output index. + */ +export function parseUtxoRef(ref: string): { txHash: string; index: number } { + if (!CARDANO_UTXO_REF_REGEX.test(ref)) { + throw new Error(`Invalid Cardano UTXO reference: ${ref}`); + } + const [txHash, indexStr] = ref.split("#"); + return { txHash: txHash.toLowerCase(), index: parseInt(indexStr, 10) }; +} + +/** + * Encodes a Cardano payment payload as a JSON object suitable for the x402 + * `payload` field. Matches the spec's PAYMENT-SIGNATURE schema. + * + * @param payload - The payload to serialize. + * @returns A plain object representation of the payload. + */ +export function encodeCardanoPayload(payload: ExactCardanoPayload): Record { + return { transaction: payload.transaction, nonce: payload.nonce }; +} + +/** + * Reads a Cardano payment payload back out of an arbitrary record. + * + * @param raw - The raw payload coming from the x402 envelope. + * @returns The typed Cardano payload. + */ +export function decodeCardanoPayload(raw: Record): ExactCardanoPayload { + const transaction = raw.transaction; + const nonce = raw.nonce; + if (typeof transaction !== "string" || transaction.length === 0) { + throw new Error("Cardano payload is missing a transaction string"); + } + if (typeof nonce !== "string" || nonce.length === 0) { + throw new Error("Cardano payload is missing a nonce string"); + } + return { transaction, nonce }; +} + +/** + * Returns true when the supplied output pays at least `amount` of `asset` to + * `recipient`. + * + * @param output - The decoded UTXO output. + * @param recipient - The expected bech32 recipient address. + * @param asset - Asset unit (`policyId.assetNameHex`). + * @param amount - Required amount in the asset's smallest unit. + * @returns True when the output satisfies the requirement. + */ +export function outputSatisfies( + output: CardanoUtxoOutput, + recipient: string, + asset: string, + amount: bigint, +): boolean { + if (output.address !== recipient) { + return false; + } + if (asset.toLowerCase() === "lovelace") { + return output.coin >= amount; + } + const assetAmount = output.assets[asset.toLowerCase()] ?? 0n; + return assetAmount >= amount; +} + +/** + * Lazily resolves the optional Cardano serialization library + * (`@emurgo/cardano-serialization-lib-nodejs`). Throws a clear error when the + * dependency is not installed so the package can ship without forcing the + * heavy WASM bundle on consumers that only need types/constants. + * + * @returns The CSL module. + */ +export async function loadCardanoSerializationLib(): Promise { + try { + return await import("@emurgo/cardano-serialization-lib-nodejs"); + } catch (cause) { + const error = new Error( + `${ERR_CARDANO_SDK_MISSING}: install '@emurgo/cardano-serialization-lib-nodejs' to enable Cardano transaction verification`, + ); + (error as { cause?: unknown }).cause = cause; + throw error; + } +} + +/** + * Decodes a base64-encoded signed Cardano transaction into a structural + * summary using `cardano-serialization-lib`. Only the fields required by the + * facilitator are surfaced. + * + * @param transactionBase64 - The base64-encoded CBOR transaction. + * @returns The decoded transaction summary. + */ +export async function decodeCardanoTransaction( + transactionBase64: string, +): Promise { + const csl = (await loadCardanoSerializationLib()) as CslModule; + const txBytes = Buffer.from(transactionBase64, "base64"); + const tx = csl.Transaction.from_bytes(txBytes); + const body = tx.body(); + + const txHashBytes = csl.hash_transaction(body).to_bytes(); + const txHash = Buffer.from(txHashBytes).toString("hex"); + + const networkId = readNetworkId(body); + const ttlSlot = readTtl(body); + const validityStartSlot = readValidityStart(body); + + const inputs = readInputs(body); + const outputs = readOutputs(csl, body); + const { vkeyWitnessCount, scriptWitnessCount } = readWitnessCounts(tx); + + return { + txHash, + networkId, + ttlSlot, + validityStartSlot, + inputs, + outputs, + vkeyWitnessCount, + scriptWitnessCount, + }; +} + +interface CslTransactionInput { + transaction_id(): { to_bytes(): Uint8Array }; + index(): number | bigint; +} + +interface CslTransactionInputs { + len(): number; + get(index: number): CslTransactionInput; +} + +interface CslAddress { + to_bech32(): string; +} + +interface CslAssetName { + name(): Uint8Array; +} + +interface CslBigNum { + to_str(): string; +} + +interface CslAssets { + len(): number; + keys(): { len(): number; get(index: number): CslAssetName }; + get(name: CslAssetName): CslBigNum | undefined; +} + +interface CslScriptHash { + to_bytes(): Uint8Array; +} + +interface CslMultiAsset { + len(): number; + keys(): { len(): number; get(index: number): CslScriptHash }; + get(scriptHash: CslScriptHash): CslAssets | undefined; +} + +interface CslValue { + coin(): CslBigNum; + multiasset(): CslMultiAsset | undefined; +} + +interface CslTransactionOutput { + address(): CslAddress; + amount(): CslValue; +} + +interface CslTransactionOutputs { + len(): number; + get(index: number): CslTransactionOutput; +} + +interface CslTransactionBody { + inputs(): CslTransactionInputs; + outputs(): CslTransactionOutputs; + ttl?(): unknown; + ttl_bignum?(): CslBigNum | undefined; + validity_start_interval?(): unknown; + validity_start_interval_bignum?(): CslBigNum | undefined; + network_id?(): { kind(): number } | undefined; +} + +interface CslWitnessGroup { + len(): number; +} + +interface CslTransactionWitnessSet { + vkeys?(): CslWitnessGroup | undefined; + bootstraps?(): CslWitnessGroup | undefined; + native_scripts?(): CslWitnessGroup | undefined; + plutus_scripts?(): CslWitnessGroup | undefined; + plutus_v2_scripts?(): CslWitnessGroup | undefined; + plutus_v3_scripts?(): CslWitnessGroup | undefined; + redeemers?(): CslWitnessGroup | undefined; +} + +interface CslTransaction { + body(): CslTransactionBody; + witness_set(): CslTransactionWitnessSet; +} + +interface CslModule { + Transaction: { from_bytes(bytes: Uint8Array): CslTransaction }; + hash_transaction(body: CslTransactionBody): { to_bytes(): Uint8Array }; +} + +/** + * Counts the verification-relevant witnesses on a Cardano transaction. Used + * by the facilitator to reject unsigned payments in `verify()`. + * + * @param tx - The CSL transaction. + * @returns Counts of vkey/bootstrap witnesses and of script witnesses. + */ +function readWitnessCounts(tx: CslTransaction): { + vkeyWitnessCount: number; + scriptWitnessCount: number; +} { + let vkeyWitnessCount = 0; + let scriptWitnessCount = 0; + const ws = tx.witness_set(); + const sizeOf = (group: CslWitnessGroup | undefined): number => + group && typeof group.len === "function" ? group.len() : 0; + if (typeof ws.vkeys === "function") vkeyWitnessCount += sizeOf(ws.vkeys()); + if (typeof ws.bootstraps === "function") vkeyWitnessCount += sizeOf(ws.bootstraps()); + if (typeof ws.native_scripts === "function") scriptWitnessCount += sizeOf(ws.native_scripts()); + if (typeof ws.plutus_scripts === "function") scriptWitnessCount += sizeOf(ws.plutus_scripts()); + if (typeof ws.plutus_v2_scripts === "function") + scriptWitnessCount += sizeOf(ws.plutus_v2_scripts()); + if (typeof ws.plutus_v3_scripts === "function") + scriptWitnessCount += sizeOf(ws.plutus_v3_scripts()); + if (typeof ws.redeemers === "function") scriptWitnessCount += sizeOf(ws.redeemers()); + return { vkeyWitnessCount, scriptWitnessCount }; +} + +/** + * Reads the optional network_id field from the transaction body. Returns + * `undefined` when the body does not declare a network id (older era txs). + * + * @param body - The CSL transaction body. + * @returns The numeric network id or undefined. + */ +function readNetworkId(body: CslTransactionBody): number | undefined { + if (typeof body.network_id !== "function") { + return undefined; + } + const id = body.network_id(); + if (!id) { + return undefined; + } + return id.kind(); +} + +/** + * Reads the TTL slot from the transaction body using whichever accessor the + * loaded CSL build exposes (`ttl_bignum` on newer builds, `ttl` on older). + * + * @param body - The CSL transaction body. + * @returns The TTL slot or undefined. + */ +function readTtl(body: CslTransactionBody): bigint | undefined { + if (typeof body.ttl_bignum === "function") { + const v = body.ttl_bignum(); + if (v) { + return BigInt(v.to_str()); + } + } + if (typeof body.ttl === "function") { + const v = body.ttl(); + if (v == null) return undefined; + if (typeof v === "number" || typeof v === "bigint") return BigInt(v); + if (typeof v === "object" && v !== null && "to_str" in v) { + return BigInt((v as CslBigNum).to_str()); + } + } + return undefined; +} + +/** + * Reads the validity_start_interval (lower bound) from the transaction body. + * + * @param body - The CSL transaction body. + * @returns The validity-start slot or undefined. + */ +function readValidityStart(body: CslTransactionBody): bigint | undefined { + if (typeof body.validity_start_interval_bignum === "function") { + const v = body.validity_start_interval_bignum(); + if (v) return BigInt(v.to_str()); + } + if (typeof body.validity_start_interval === "function") { + const v = body.validity_start_interval(); + if (v == null) return undefined; + if (typeof v === "number" || typeof v === "bigint") return BigInt(v); + if (typeof v === "object" && v !== null && "to_str" in v) { + return BigInt((v as CslBigNum).to_str()); + } + } + return undefined; +} + +/** + * Returns the transaction inputs as `txHash#index` strings. + * + * @param body - The CSL transaction body. + * @returns Ordered list of UTXO references. + */ +function readInputs(body: CslTransactionBody): string[] { + const inputs = body.inputs(); + const result: string[] = []; + const len = inputs.len(); + for (let i = 0; i < len; i++) { + const input = inputs.get(i); + const txId = Buffer.from(input.transaction_id().to_bytes()).toString("hex"); + const idx = Number(input.index()); + result.push(`${txId}#${idx}`); + } + return result; +} + +/** + * Decodes the transaction outputs into a SDK-agnostic representation. + * + * @param csl - The loaded CSL module. + * @param body - The CSL transaction body. + * @returns Ordered decoded outputs. + */ +function readOutputs(csl: CslModule, body: CslTransactionBody): CardanoUtxoOutput[] { + void csl; + const outputs = body.outputs(); + const result: CardanoUtxoOutput[] = []; + const len = outputs.len(); + for (let i = 0; i < len; i++) { + const out = outputs.get(i); + const address = out.address().to_bech32(); + const value = out.amount(); + const coin = BigInt(value.coin().to_str()); + const assets: Record = {}; + const multi = value.multiasset(); + if (multi) { + const scriptHashes = multi.keys(); + const policyCount = scriptHashes.len(); + for (let p = 0; p < policyCount; p++) { + const scriptHash = scriptHashes.get(p); + const policyHex = Buffer.from(scriptHash.to_bytes()).toString("hex").toLowerCase(); + const inner = multi.get(scriptHash); + if (!inner) continue; + const names = inner.keys(); + const nameCount = names.len(); + for (let n = 0; n < nameCount; n++) { + const assetName = names.get(n); + const nameHex = Buffer.from(assetName.name()).toString("hex").toLowerCase(); + const qty = inner.get(assetName); + if (!qty) continue; + assets[`${policyHex}.${nameHex}`] = BigInt(qty.to_str()); + } + } + } + result.push({ address, coin, assets }); + } + return result; +} diff --git a/typescript/packages/mechanisms/cardano/test/unit/constants.test.ts b/typescript/packages/mechanisms/cardano/test/unit/constants.test.ts new file mode 100644 index 0000000000..ef4ae80ac6 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/constants.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + CARDANO_ADDRESS_REGEX, + CARDANO_ASSET_REGEX, + CARDANO_MAINNET_CAIP2, + CARDANO_NETWORKS, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, + CARDANO_UTXO_REF_REGEX, + ERR_NETWORK_MISMATCH, + getCardanoNetworkId, + getDefaultUsdmAsset, + isCardanoNetwork, + SCHEME_EXACT, + USDM_MAINNET_ASSET, + USDM_MAINNET_POLICY_ID, + USDM_PREPROD_ASSET, +} from "../../src/constants"; + +describe("Cardano Constants", () => { + it("declares the spec network identifiers verbatim", () => { + expect(CARDANO_MAINNET_CAIP2).toBe("cardano:mainnet"); + expect(CARDANO_PREPROD_CAIP2).toBe("cardano:preprod"); + expect(CARDANO_PREVIEW_CAIP2).toBe("cardano:preview"); + expect(CARDANO_NETWORKS).toEqual([ + CARDANO_MAINNET_CAIP2, + CARDANO_PREPROD_CAIP2, + CARDANO_PREVIEW_CAIP2, + ]); + }); + + it("uses 'exact' as the scheme identifier", () => { + expect(SCHEME_EXACT).toBe("exact"); + }); + + it("provides USDM defaults that match the spec example", () => { + expect(USDM_MAINNET_POLICY_ID).toHaveLength(56); + expect(USDM_MAINNET_ASSET).toBe( + "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d", + ); + expect(getDefaultUsdmAsset(CARDANO_MAINNET_CAIP2)).toBe(USDM_MAINNET_ASSET); + expect(getDefaultUsdmAsset(CARDANO_PREPROD_CAIP2)).toBe(USDM_PREPROD_ASSET); + }); + + it("maps networks to Cardano network ids", () => { + expect(getCardanoNetworkId(CARDANO_MAINNET_CAIP2)).toBe(1); + expect(getCardanoNetworkId(CARDANO_PREPROD_CAIP2)).toBe(0); + expect(getCardanoNetworkId(CARDANO_PREVIEW_CAIP2)).toBe(0); + expect(() => getCardanoNetworkId("ethereum:1")).toThrow(/Unsupported Cardano network/); + }); + + it("recognises Cardano networks via isCardanoNetwork", () => { + expect(isCardanoNetwork("cardano:mainnet")).toBe(true); + expect(isCardanoNetwork("cardano:preprod")).toBe(true); + expect(isCardanoNetwork("cardano:preview")).toBe(true); + expect(isCardanoNetwork("cardano:mainnet-foo")).toBe(false); + expect(isCardanoNetwork("ethereum:1")).toBe(false); + }); + + it("validates Cardano asset units", () => { + expect(CARDANO_ASSET_REGEX.test(USDM_MAINNET_ASSET)).toBe(true); + expect(CARDANO_ASSET_REGEX.test("lovelace")).toBe(true); + expect( + CARDANO_ASSET_REGEX.test("c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad."), + ).toBe(true); + expect(CARDANO_ASSET_REGEX.test("notapolicy.0014df105553444d")).toBe(false); + expect( + CARDANO_ASSET_REGEX.test("c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad"), + ).toBe(false); + }); + + it("validates Cardano payment addresses", () => { + expect(CARDANO_ADDRESS_REGEX.test("addr1qxabcdefghijklmnpqrstuvwxyz0123456789")).toBe(true); + expect(CARDANO_ADDRESS_REGEX.test("addr_test1qxabc1234567890")).toBe(true); + expect(CARDANO_ADDRESS_REGEX.test("Ae2tdPwUPEZ123")).toBe(false); + expect(CARDANO_ADDRESS_REGEX.test("0xabc")).toBe(false); + }); + + it("validates UTXO references", () => { + const validRef = `${"a".repeat(64)}#0`; + expect(CARDANO_UTXO_REF_REGEX.test(validRef)).toBe(true); + expect(CARDANO_UTXO_REF_REGEX.test(`${"a".repeat(63)}#0`)).toBe(false); + expect(CARDANO_UTXO_REF_REGEX.test(`${"a".repeat(64)}#`)).toBe(false); + expect(CARDANO_UTXO_REF_REGEX.test(`${"a".repeat(64)}#-1`)).toBe(false); + }); + + it("exposes a stable network mismatch error code", () => { + expect(ERR_NETWORK_MISMATCH).toBe("network_mismatch"); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts b/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts new file mode 100644 index 0000000000..c300618118 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from "vitest"; +import { ExactCardanoScheme as ExactCardanoClient } from "../../src/exact/client/scheme"; +import { + ExactCardanoScheme as ExactCardanoFacilitator, + supportedCardanoNetworks, +} from "../../src/exact/facilitator/scheme"; +import { ExactCardanoScheme as ExactCardanoServer } from "../../src/exact/server/scheme"; +import { + CARDANO_MAINNET_CAIP2, + CARDANO_NETWORKS, + CARDANO_PREPROD_CAIP2, + USDM_MAINNET_ASSET, +} from "../../src/constants"; +import type { ClientCardanoSigner, FacilitatorCardanoSigner } from "../../src/signer"; +import type { PaymentRequirements } from "@x402/core/types"; + +const TX_HASH = "a".repeat(64); + +const RECIPIENT = "addr1qxytestrecipientaddress00"; + +const buildRequirements = (overrides: Partial = {}): PaymentRequirements => ({ + scheme: "exact", + network: CARDANO_MAINNET_CAIP2, + asset: USDM_MAINNET_ASSET, + amount: "10000", + payTo: RECIPIENT, + maxTimeoutSeconds: 600, + extra: {}, + ...overrides, +}); + +const stubSigner: ClientCardanoSigner = { + getAddress: () => "addr1qxsomeaddress00", + signPaymentTransaction: () => ({ + transaction: "AAAA", + nonce: `${TX_HASH}#0`, + }), +}; + +const stubFacilitatorSigner: FacilitatorCardanoSigner = { + getAddresses: () => ["addr1qfacilitator00"], + getUtxo: async () => ({ exists: true, address: "addr1qpayer00" }), + getCurrentSlot: async () => 100n, + submitTransaction: async () => ({ txHash: "deadbeef", status: "confirmed" }), +}; + +describe("ExactCardanoScheme client", () => { + const client = new ExactCardanoClient(stubSigner); + + it("declares the 'exact' scheme", () => { + expect(client.scheme).toBe("exact"); + }); + + it("rejects non-Cardano networks", async () => { + const reqs = buildRequirements({ network: "ethereum:1" }); + await expect(client.createPaymentPayload(2, reqs)).rejects.toThrow( + /Unsupported Cardano network/, + ); + }); + + it("rejects invalid pay-to addresses", async () => { + await expect( + client.createPaymentPayload(2, buildRequirements({ payTo: "0xnope" })), + ).rejects.toThrow(/Invalid Cardano pay-to address/); + }); + + it("rejects invalid asset units", async () => { + await expect( + client.createPaymentPayload(2, buildRequirements({ asset: "not.a.unit" })), + ).rejects.toThrow(/Invalid Cardano asset unit/); + }); + + it("rejects non-numeric amounts", async () => { + await expect( + client.createPaymentPayload(2, buildRequirements({ amount: "10.5" })), + ).rejects.toThrow(/Amount must be a non-negative integer/); + }); + + it("returns a payload from the signer for valid requirements", async () => { + const result = await client.createPaymentPayload(2, buildRequirements()); + expect(result.x402Version).toBe(2); + expect(result.payload).toEqual({ + transaction: "AAAA", + nonce: `${TX_HASH}#0`, + }); + }); + + it("rejects signer responses with invalid nonce", async () => { + const badSigner: ClientCardanoSigner = { + getAddress: () => "addr1q", + signPaymentTransaction: () => ({ transaction: "AA", nonce: "bad" }), + }; + const c = new ExactCardanoClient(badSigner); + await expect(c.createPaymentPayload(2, buildRequirements())).rejects.toThrow( + /Cardano signer returned an invalid nonce/, + ); + }); +}); + +describe("ExactCardanoScheme facilitator", () => { + it("declares CAIP family and scheme identifier", () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + expect(facilitator.scheme).toBe("exact"); + expect(facilitator.caipFamily).toBe("cardano:*"); + }); + + it("returns its addresses via getSigners", () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + expect(facilitator.getSigners(CARDANO_MAINNET_CAIP2)).toEqual(["addr1qfacilitator00"]); + }); + + it("returns undefined for getExtra (no metadata required by default)", () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + expect(facilitator.getExtra(CARDANO_PREPROD_CAIP2)).toBeUndefined(); + }); + + it("rejects payloads when networks differ", async () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + const result = await facilitator.verify( + { + x402Version: 2, + accepted: buildRequirements({ network: "cardano:preview" }), + payload: { transaction: "AA", nonce: `${TX_HASH}#0` }, + }, + buildRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("network_mismatch"); + }); + + it("rejects payloads with non-Cardano networks", async () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + const reqs = buildRequirements({ network: "ethereum:1" }); + const result = await facilitator.verify( + { x402Version: 2, accepted: reqs, payload: { transaction: "AA", nonce: `${TX_HASH}#0` } }, + reqs, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("network_mismatch"); + }); + + it("rejects payloads with malformed nonce", async () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + const reqs = buildRequirements(); + const result = await facilitator.verify( + { x402Version: 2, accepted: reqs, payload: { transaction: "AA", nonce: "bad" } }, + reqs, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_exact_cardano_payload_nonce_invalid"); + }); + + it("rejects payloads missing transaction", async () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + const reqs = buildRequirements(); + const result = await facilitator.verify( + { x402Version: 2, accepted: reqs, payload: { nonce: `${TX_HASH}#0` } }, + reqs, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_exact_cardano_payload"); + }); + + it("exposes the supported networks", () => { + expect(supportedCardanoNetworks()).toEqual(CARDANO_NETWORKS); + }); + + it("rejects script assetTransferMethod by default to avoid silent acceptance", async () => { + // The base implementation cannot reconstruct script addresses; integrators + // must override `runMethodSpecificChecks`. Until then, script payments are + // refused even when the funds and address match. + class TestFacilitator extends ExactCardanoFacilitator {} + const facilitator = new TestFacilitator(stubFacilitatorSigner); + // We cannot easily exercise the full happy path without a CSL build, but + // we can confirm the method rejects by reaching into the protected hook. + const result = await ( + facilitator as unknown as { + runMethodSpecificChecks: ( + extra: Record | undefined, + payTo: string, + payer: string, + ) => Promise<{ ok: true } | { ok: false; reason: string }>; + } + ).runMethodSpecificChecks( + { + assetTransferMethod: "script", + scriptHash: "deadbeef", + }, + RECIPIENT, + "addr1qpayer00", + ); + expect(result).toEqual({ + ok: false, + reason: "invalid_exact_cardano_payload_script_address_mismatch", + }); + }); + + it("rejects a second concurrent settle as duplicate", async () => { + class FakeOk extends ExactCardanoFacilitator { + override async verify() { + return { isValid: true, payer: "addr1qpayer00" }; + } + } + const facilitator = new FakeOk(stubFacilitatorSigner); + const reqs = buildRequirements(); + const payload = { + x402Version: 2, + accepted: reqs, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }; + const first = await facilitator.settle(payload, reqs); + const second = await facilitator.settle(payload, reqs); + expect(first.success).toBe(true); + expect(second.success).toBe(false); + expect(second.errorReason).toBe("duplicate_settlement"); + }); + + it("rejects mempool-only settlements when acceptMempool is disabled (default)", async () => { + const mempoolSigner: FacilitatorCardanoSigner = { + ...stubFacilitatorSigner, + submitTransaction: async () => ({ txHash: "abc", status: "mempool" }), + }; + // Bypass verify() by stubbing it via subclass for this isolation test. + class FakeOk extends ExactCardanoFacilitator { + override async verify() { + return { isValid: true, payer: "addr1qpayer00" }; + } + } + const facilitator = new FakeOk(mempoolSigner); + const reqs = buildRequirements(); + const settle = await facilitator.settle( + { + x402Version: 2, + accepted: reqs, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + reqs, + ); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("exact_cardano_settlement_not_confirmed"); + expect(settle.transaction).toBe("abc"); + }); + + it("accepts mempool-only settlements when acceptMempool is true", async () => { + const mempoolSigner: FacilitatorCardanoSigner = { + ...stubFacilitatorSigner, + submitTransaction: async () => ({ txHash: "abc", status: "mempool" }), + }; + class FakeOk extends ExactCardanoFacilitator { + override async verify() { + return { isValid: true, payer: "addr1qpayer00" }; + } + } + const facilitator = new FakeOk(mempoolSigner, { acceptMempool: true }); + const reqs = buildRequirements(); + const settle = await facilitator.settle( + { + x402Version: 2, + accepted: reqs, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + reqs, + ); + expect(settle.success).toBe(true); + expect((settle.extensions as { status?: string } | undefined)?.status).toBe("mempool"); + }); +}); + +describe("ExactCardanoScheme server", () => { + it("parses Money strings to USDM atomic units", async () => { + const server = new ExactCardanoServer(); + const result = await server.parsePrice("$1.50", CARDANO_MAINNET_CAIP2); + expect(result.amount).toBe("1500000"); + expect(result.asset).toBe(USDM_MAINNET_ASSET); + }); + + it("passes through AssetAmount", async () => { + const server = new ExactCardanoServer(); + const result = await server.parsePrice( + { amount: "12345", asset: USDM_MAINNET_ASSET, extra: { tier: "premium" } }, + CARDANO_MAINNET_CAIP2, + ); + expect(result.amount).toBe("12345"); + expect(result.extra?.tier).toBe("premium"); + }); + + it("supports MoneyParser chaining", async () => { + const server = new ExactCardanoServer(); + server.registerMoneyParser(async amount => + amount > 100 + ? { amount: (amount * 1e6).toString(), asset: USDM_MAINNET_ASSET, extra: { tier: "vip" } } + : null, + ); + const big = await server.parsePrice("150", CARDANO_MAINNET_CAIP2); + expect(big.extra?.tier).toBe("vip"); + const small = await server.parsePrice("1", CARDANO_MAINNET_CAIP2); + expect(small.extra?.tier).toBeUndefined(); + expect(small.amount).toBe("1000000"); + }); + + it("enhancePaymentRequirements merges supported kind extra", async () => { + const server = new ExactCardanoServer(); + const baseRequirements = buildRequirements({ extra: { foo: "bar" } }); + const enhanced = await server.enhancePaymentRequirements( + baseRequirements, + { + x402Version: 2, + scheme: "exact", + network: CARDANO_MAINNET_CAIP2, + extra: { policy: "default" }, + }, + [], + ); + expect(enhanced.extra).toEqual({ policy: "default", foo: "bar" }); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/security.test.ts b/typescript/packages/mechanisms/cardano/test/unit/security.test.ts new file mode 100644 index 0000000000..8ac9eba478 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/security.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; + +// Stub the optional CSL-bound decoder so the test does not require the WASM +// dependency. The mock must be declared before importing the facilitator +// scheme so the bound import inside that module picks up the stub. +vi.mock("../../src/utils", async original => { + const actual = (await original()) as Record; + return { + ...actual, + decodeCardanoTransaction: vi.fn(), + }; +}); + +import { decodeCardanoTransaction } from "../../src/utils"; +import { ExactCardanoScheme as ExactCardanoFacilitator } from "../../src/exact/facilitator/scheme"; +import { CARDANO_MAINNET_CAIP2, USDM_MAINNET_ASSET } from "../../src/constants"; +import type { FacilitatorCardanoSigner } from "../../src/signer"; +import type { PaymentRequirements } from "@x402/core/types"; + +const TX_HASH = "a".repeat(64); +const RECIPIENT = "addr1qxytestrecipientaddress00"; + +const buildRequirements = (extra: Record = {}): PaymentRequirements => ({ + scheme: "exact", + network: CARDANO_MAINNET_CAIP2, + asset: USDM_MAINNET_ASSET, + amount: "10000", + payTo: RECIPIENT, + maxTimeoutSeconds: 600, + extra, +}); + +const stubSigner: FacilitatorCardanoSigner = { + getAddresses: () => ["addr1qfacilitator00"], + getUtxo: async () => ({ exists: true, address: "addr1qpayer00" }), + getCurrentSlot: async () => 100n, + submitTransaction: async () => ({ txHash: "deadbeef", status: "confirmed" }), +}; + +describe("Cardano facilitator security", () => { + it("reads assetTransferMethod from canonical requirements, not client-echoed accepted", async () => { + let capturedExtra: Record | undefined; + + class CaptureFacilitator extends ExactCardanoFacilitator { + protected override async runMethodSpecificChecks( + extra: Record | undefined, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + capturedExtra = extra ? { ...extra } : undefined; + return { ok: true }; + } + } + + vi.mocked(decodeCardanoTransaction).mockResolvedValueOnce({ + txHash: "abc", + networkId: 1, + ttlSlot: undefined, + validityStartSlot: undefined, + inputs: [`${TX_HASH}#0`], + outputs: [ + { + address: RECIPIENT, + coin: 0n, + assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, + }, + ], + vkeyWitnessCount: 1, + scriptWitnessCount: 0, + }); + + const facilitator = new CaptureFacilitator(stubSigner); + const serverReqs = buildRequirements({ + assetTransferMethod: "script", + scriptHash: "deadbeef", + }); + const payload = { + x402Version: 2, + accepted: buildRequirements({ assetTransferMethod: "default" }), + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }; + const result = await facilitator.verify(payload, serverReqs); + expect(result.isValid).toBe(true); + expect(capturedExtra).toEqual({ + assetTransferMethod: "script", + scriptHash: "deadbeef", + }); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/types.test.ts b/typescript/packages/mechanisms/cardano/test/unit/types.test.ts new file mode 100644 index 0000000000..ee69df430a --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/types.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { + CardanoExtraDefault, + CardanoExtraMasumi, + CardanoExtraScript, + ExactCardanoPayload, +} from "../../src/types"; + +describe("Cardano Types", () => { + it("accepts a default extra", () => { + const extra: CardanoExtraDefault = {}; + expect(extra).toBeDefined(); + }); + + it("accepts a Masumi extra with all required fields", () => { + const extra: CardanoExtraMasumi = { + assetTransferMethod: "masumi", + identifierFromPurchaser: "aabbaabb11221122aabb", + sellerVkey: "deadbeef", + paymentType: "Web3CardanoV1", + blockchainIdentifier: "blockchain_identifier", + payByTime: "1713626260", + submitResultTime: "1713636260", + unlockTime: "1713636260", + externalDisputeUnlockTime: "1713636260", + agentIdentifier: "agent_identifier", + inputHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }; + expect(extra.assetTransferMethod).toBe("masumi"); + }); + + it("accepts a Script extra with parameters", () => { + const extra: CardanoExtraScript = { + assetTransferMethod: "script", + scriptHash: "abc", + script: { type: "plutusV3", code: "deadbeef" }, + parameters: { greeting: { type: "bytes", value: "Hello" } }, + }; + expect(extra.assetTransferMethod).toBe("script"); + expect(extra.parameters?.greeting.value).toBe("Hello"); + }); + + it("accepts a payload with transaction and nonce", () => { + const payload: ExactCardanoPayload = { + transaction: "AAA=", + nonce: `${"a".repeat(64)}#0`, + }; + expect(payload.transaction).toBe("AAA="); + expect(payload.nonce.endsWith("#0")).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts b/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts new file mode 100644 index 0000000000..fcf53c3462 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + decodeCardanoPayload, + encodeCardanoPayload, + outputSatisfies, + parseAssetUnit, + parseUtxoRef, +} from "../../src/utils"; + +const ASSET = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d"; +const TX_HASH = "a".repeat(64); + +describe("Cardano Utils", () => { + it("parses asset units", () => { + expect(parseAssetUnit(ASSET)).toEqual({ + policyId: "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad", + assetNameHex: "0014df105553444d", + }); + expect(() => parseAssetUnit("not-an-asset")).toThrow(); + }); + + it("parses UTXO refs", () => { + expect(parseUtxoRef(`${TX_HASH}#3`)).toEqual({ txHash: TX_HASH, index: 3 }); + expect(() => parseUtxoRef(`${TX_HASH}#-1`)).toThrow(); + }); + + it("encodes and decodes payloads round-trip", () => { + const encoded = encodeCardanoPayload({ transaction: "tx", nonce: `${TX_HASH}#0` }); + const decoded = decodeCardanoPayload(encoded); + expect(decoded).toEqual({ transaction: "tx", nonce: `${TX_HASH}#0` }); + expect(() => decodeCardanoPayload({})).toThrow(); + expect(() => decodeCardanoPayload({ transaction: "tx" })).toThrow(); + }); + + it("decides output satisfaction by recipient + asset + amount", () => { + const output = { + address: "addr1payee", + coin: 0n, + assets: { [ASSET.toLowerCase()]: 12_000n }, + }; + expect(outputSatisfies(output, "addr1payee", ASSET, 10_000n)).toBe(true); + expect(outputSatisfies(output, "addr1payee", ASSET, 13_000n)).toBe(false); + expect(outputSatisfies(output, "addr1other", ASSET, 1n)).toBe(false); + expect(outputSatisfies(output, "addr1payee", "deadbeef" + ".".repeat(0), 1n)).toBe(false); + }); + + it("special-cases lovelace against output.coin", () => { + const output = { address: "addr1payee", coin: 5_000_000n, assets: {} }; + expect(outputSatisfies(output, "addr1payee", "lovelace", 5_000_000n)).toBe(true); + expect(outputSatisfies(output, "addr1payee", "lovelace", 5_000_001n)).toBe(false); + expect(parseAssetUnit("lovelace")).toEqual({ policyId: "", assetNameHex: "" }); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/tsconfig.json b/typescript/packages/mechanisms/cardano/tsconfig.json new file mode 100644 index 0000000000..f600458996 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/typescript/packages/mechanisms/cardano/tsup.config.ts b/typescript/packages/mechanisms/cardano/tsup.config.ts new file mode 100644 index 0000000000..1143bfeaf5 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "tsup"; + +const baseConfig = { + entry: { + index: "src/index.ts", + "exact/client/index": "src/exact/client/index.ts", + "exact/server/index": "src/exact/server/index.ts", + "exact/facilitator/index": "src/exact/facilitator/index.ts", + }, + dts: { + resolve: true, + }, + sourcemap: true, + target: "es2020", +}; + +export default defineConfig([ + { + ...baseConfig, + format: "esm", + outDir: "dist/esm", + clean: true, + }, + { + ...baseConfig, + format: "cjs", + outDir: "dist/cjs", + clean: false, + }, +]); diff --git a/typescript/packages/mechanisms/cardano/vitest.config.ts b/typescript/packages/mechanisms/cardano/vitest.config.ts new file mode 100644 index 0000000000..a03dc8bc65 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/vitest.config.ts @@ -0,0 +1,11 @@ +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => ({ + test: { + env: loadEnv(mode, process.cwd(), ""), + exclude: ["**/node_modules/**", "**/dist/**", "**/test/integrations/**"], + }, + plugins: [tsconfigPaths({ projects: ["."] })], +})); diff --git a/typescript/packages/mechanisms/cardano/vitest.integration.config.ts b/typescript/packages/mechanisms/cardano/vitest.integration.config.ts new file mode 100644 index 0000000000..763ef8ac34 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/vitest.integration.config.ts @@ -0,0 +1,12 @@ +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => ({ + test: { + env: loadEnv(mode, process.cwd(), ""), + include: ["**/test/integrations/**/*.test.ts"], + testTimeout: 120_000, + }, + plugins: [tsconfigPaths({ projects: ["."] })], +})); diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 5f5431b166..fb5d3d9dae 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -1296,6 +1296,61 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.13)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.5)(yaml@2.8.1) + packages/mechanisms/cardano: + dependencies: + '@emurgo/cardano-serialization-lib-nodejs': + specifier: '>=11.5.0' + version: 15.0.3 + '@x402/core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.34.0 + '@types/node': + specifier: ^22.13.4 + version: 22.19.17 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.49.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) + eslint: + specifier: ^9.24.0 + version: 9.34.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.34.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.34.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.5)(typescript@5.9.2)(yaml@2.8.1) + tsx: + specifier: ^4.19.2 + version: 4.20.5 + typescript: + specifier: ^5.7.3 + version: 5.9.2 + vite: + specifier: ^6.2.6 + version: 6.3.5(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.5)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.5)(yaml@2.8.1)) + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.5)(yaml@2.8.1) + packages/mechanisms/evm: dependencies: '@x402/core': @@ -2431,6 +2486,9 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emurgo/cardano-serialization-lib-nodejs@15.0.3': + resolution: {integrity: sha512-CZkAF7P3Ip3gUCAa6v93DLKp9hGqsfE6F/b3Qrqvym7rEJKi3j+dDk/OPxtmynWWGHhqXL85vzFOOwVduhLAjA==} + '@es-joy/jsdoccomment@0.50.2': resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==} engines: {node: '>=18'} @@ -5837,9 +5895,6 @@ packages: bn.js@5.2.1: resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} - bn.js@5.2.2: - resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} - bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} @@ -11256,6 +11311,8 @@ snapshots: tslib: 2.8.1 optional: true + '@emurgo/cardano-serialization-lib-nodejs@15.0.3': {} + '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.8 @@ -16649,8 +16706,6 @@ snapshots: bn.js@5.2.1: {} - bn.js@5.2.2: {} - bn.js@5.2.3: {} body-parser@1.20.3: