Skip to content
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changelog

All notable changes to this project will be documented in this file.

## [0.3.0] - 2025-01-06

### Added
- **Protocol v2 support** - Now supports x402 protocol v2 with CAIP-2 network identifiers and `PAYMENT-SIGNATURE` header
- **Multi-chain accept** - `config.accept(chain:, currency:)` allows accepting payments on multiple chains simultaneously
- **Per-endpoint version** - Override protocol version per endpoint with `x402_paywall(amount:, version:)`
- **Custom chain CAIP-2 lookup** - `from_caip2()` now supports reverse lookup for custom registered chains

### Changed
- Default protocol version is now v2
- v2 responses use CAIP-2 format for network identifiers (e.g., `eip155:84532` instead of `base-sepolia`)

### Fixed
- PaymentPayload v2 format now includes `scheme` and `network` at top level
- PaymentRequirement includes all required facilitator fields (`maxAmountRequired`, `description`, `resource`, `mimeType`)

## [0.2.1] - Previous Release

- Initial stable release with v1 protocol support
- Custom chain and token registration
- Optimistic and non-optimistic settlement modes
164 changes: 164 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# x402-rails

## Now supporting x402 v2!

![Coverage](./coverage/coverage.svg)

Accept instant blockchain micropayments in your Rails applications using the [x402 payment protocol](https://www.x402.org/).
Expand Down Expand Up @@ -164,6 +166,7 @@ end
| `chain` | No | `"base-sepolia"` | Blockchain network to use (`base-sepolia`, `base`, `avalanche-fuji`, `avalanche`) |
| `currency` | No | `"USDC"` | Payment token symbol (currently only USDC supported) |
| `optimistic` | No | `true` | Settlement mode (see Optimistic vs Non-Optimistic Mode below) |
| `version` | No | `2` | Protocol version (1 or 2). See Protocol Versions section |
| `rpc_urls` | No | `{}` | Custom RPC endpoint URLs per chain (see Custom RPC URLs below) |

### Custom RPC URLs
Expand Down Expand Up @@ -207,6 +210,167 @@ X402_AVALANCHE_FUJI_RPC_URL=https://your-fuji-rpc.quiknode.pro/your-key

If no custom RPC URL is configured, it will default to the public QuickNode RPC urls.

### Custom Chains and Tokens

You can register custom EVM chains and tokens beyond the built-in options.

#### Register a Custom Chain

Add support for any EVM-compatible chain:

```ruby
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']

# Register Polygon mainnet
config.register_chain(
name: "polygon",
chain_id: 137,
standard: "eip155"
)

# Register the token for that chain
config.register_token(
chain: "polygon",
symbol: "USDC",
address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
decimals: 6,
name: "USD Coin",
version: "2"
)

config.chain = "polygon"
config.currency = "USDC"
end
```

#### Register a Custom Token on a Built-in Chain

Accept different tokens on existing chains:

```ruby
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']

# Accept WETH on Base instead of USDC
config.register_token(
chain: "base",
symbol: "WETH",
address: "0x4200000000000000000000000000000000000006",
decimals: 18,
name: "Wrapped Ether",
version: "1"
)

config.chain = "base"
config.currency = "WETH"
end
```

#### Token Registration Parameters

| Parameter | Required | Description |
| ---------- | -------- | ----------------------------------------------- |
| `chain` | Yes | Chain name (built-in or custom registered) |
| `symbol` | Yes | Token symbol (e.g., "USDC", "WETH") |
| `address` | Yes | Token contract address |
| `decimals` | Yes | Token decimals (e.g., 6 for USDC, 18 for WETH) |
| `name` | Yes | Token name for EIP-712 domain |
| `version` | No | EIP-712 version (default: "1") |

**Note:** Custom chains and tokens are only supported for EVM (eip155) networks. Solana chains use a different implementation.

### Accept Multiple Payment Options

Allow clients to pay on any of several supported chains by using `config.accept()`:

```ruby
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']

# Register a custom chain
config.register_chain(name: "polygon-amoy", chain_id: 80002, standard: "eip155")
config.register_token(
chain: "polygon-amoy",
symbol: "USDC",
address: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582",
decimals: 6,
name: "USD Coin",
version: "2"
)

# Accept payments on multiple chains
config.accept(chain: "base-sepolia", currency: "USDC")
config.accept(chain: "polygon-amoy", currency: "USDC")
end
```

When `config.accept()` is used, the 402 response will include all accepted payment options:

```json
{
"accepts": [
{ "network": "eip155:84532", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", ... },
{ "network": "eip155:80002", "asset": "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", ... }
]
}
```

Clients can then choose which chain to pay on based on their preferences or available funds.

**Per-accept wallet addresses:** You can specify different recipient addresses per chain:

```ruby
config.accept(chain: "base-sepolia", currency: "USDC", wallet_address: "0xWallet1")
config.accept(chain: "polygon-amoy", currency: "USDC", wallet_address: "0xWallet2")
```

**Fallback behavior:** If no `config.accept()` calls are made, the default `config.chain` and `config.currency` are used.

## Protocol Versions

x402-rails supports both v1 and v2 of the x402 protocol. **v2 is the default**.

### v2 (Default)

```ruby
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
config.version = 2 # Default, can be omitted
end
```

v2 uses CAIP-2 network identifiers (`eip155:84532`) and the `PAYMENT-SIGNATURE` header.

### v1 (Legacy)

```ruby
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
config.version = 1
end
```

v1 uses simple network names (`base-sepolia`) and the `X-PAYMENT` header.

### Per-Endpoint Version

Override the version for specific endpoints:

```ruby
def premium_v2
x402_paywall(amount: 0.001, version: 2)
return if performed?
render json: { data: "v2 endpoint" }
end

def legacy_v1
x402_paywall(amount: 0.001, version: 1)
return if performed?
render json: { data: "v1 endpoint" }
end
```

## Environment Variables

Configure via environment variables:
Expand Down
123 changes: 117 additions & 6 deletions lib/x402/chains.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ module X402
rpc_url: "https://floral-patient-panorama.avalanche-mainnet.quiknode.pro/ext/bc/C/rpc",
usdc_address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
explorer_url: "https://snowtrace.io"
},
"solana-devnet" => {
chain_id: 103,
rpc_url: "https://api.devnet.solana.com",
usdc_address: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
explorer_url: "https://explorer.solana.com/?cluster=devnet",
fee_payer: "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
},
"solana" => {
chain_id: 101,
rpc_url: "https://api.mainnet-beta.solana.com",
usdc_address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
explorer_url: "https://explorer.solana.com",
fee_payer: "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
}
}.freeze

Expand Down Expand Up @@ -54,24 +68,76 @@ module X402
decimals: 6,
name: "USDC", # Mainnet uses "USDC"
version: "2"
},
"solana-devnet" => {
symbol: "USDC",
decimals: 6,
name: "USDC"
},
"solana" => {
symbol: "USDC",
decimals: 6,
name: "USD Coin"
}
}.freeze

CAIP2_MAPPING = {
"base-sepolia" => "eip155:84532",
"base" => "eip155:8453",
"avalanche-fuji" => "eip155:43113",
"avalanche" => "eip155:43114",
"solana-devnet" => "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
"solana" => "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
}.freeze

REVERSE_CAIP2_MAPPING = CAIP2_MAPPING.invert.freeze

class << self
def chain_config(chain_name)
CHAINS[chain_name] || raise(ConfigurationError, "Unsupported chain: #{chain_name}")
custom = X402.configuration.chain_config(chain_name)
return custom if custom

CHAINS[chain_name] || raise(ConfigurationError, "Unsupported chain: #{chain_name}. Register with config.register_chain()")
end

def currency_config_for_chain(chain_name)
CURRENCY_BY_CHAIN[chain_name] || raise(ConfigurationError, "Unsupported chain for currency: #{chain_name}")
end

def supported_chains
CHAINS.keys
CHAINS.keys + X402.configuration.custom_chains.keys
end

def usdc_address_for(chain_name)
chain_config(chain_name)[:usdc_address]
builtin = CHAINS[chain_name]
builtin ? builtin[:usdc_address] : nil
end

def asset_address_for(chain_name, symbol = nil)
symbol ||= X402.configuration.currency

custom = X402.configuration.token_config(chain_name, symbol)
return custom[:address] if custom

if symbol.upcase == "USDC"
builtin = CHAINS[chain_name]
return builtin[:usdc_address] if builtin && builtin[:usdc_address]
end

raise ConfigurationError, "Unknown token #{symbol} for chain #{chain_name}. Register with config.register_token()"
end

def token_config_for(chain_name, symbol = nil)
symbol ||= X402.configuration.currency

custom = X402.configuration.token_config(chain_name, symbol)
return custom if custom

if symbol.upcase == "USDC" && CURRENCY_BY_CHAIN[chain_name]
currency_config_for_chain(chain_name)
else
raise ConfigurationError, "Unknown token #{symbol} for chain #{chain_name}. Register with config.register_token()"
end
end

def currency_decimals_for_chain(chain_name)
Expand All @@ -86,12 +152,57 @@ def rpc_url_for(chain_name)
return config.rpc_urls[chain_name] if config.rpc_urls[chain_name]

# Check environment variable
env_var_name = "X402_#{chain_name.upcase.gsub('-', '_')}_RPC_URL"
env_rpc = ENV[env_var_name]
return env_rpc if env_rpc && !env_rpc.empty?
# For Solana chains, use a single X402_SOLANA_RPC_URL env var
if solana_chain?(chain_name)
env_rpc = ENV["X402_SOLANA_RPC_URL"]
return env_rpc if env_rpc && !env_rpc.empty?
else
env_var_name = "X402_#{chain_name.upcase.gsub('-', '_')}_RPC_URL"
env_rpc = ENV[env_var_name]
return env_rpc if env_rpc && !env_rpc.empty?
end

# Fall back to default
chain_config(chain_name)[:rpc_url]
end

def fee_payer_for(chain_name)
# Priority: 1) Programmatic config, 2) ENV variable, 3) Default from CHAINS
config = X402.configuration

# Check programmatic configuration
return config.fee_payer if config.fee_payer && !config.fee_payer.empty?

# Check environment variable
env_fee_payer = ENV["X402_FEE_PAYER"]
return env_fee_payer if env_fee_payer && !env_fee_payer.empty?

# Fall back to default from chain config
chain_config(chain_name)[:fee_payer]
end

def to_caip2(network_name)
custom = X402.configuration.chain_config(network_name)
if custom
return "#{custom[:standard]}:#{custom[:chain_id]}"
end

CAIP2_MAPPING[network_name] || raise(ConfigurationError, "No CAIP-2 mapping for: #{network_name}")
end

def from_caip2(caip2_string)
return REVERSE_CAIP2_MAPPING[caip2_string] if REVERSE_CAIP2_MAPPING[caip2_string]

X402.configuration.custom_chains.each do |name, config|
caip2 = "#{config[:standard]}:#{config[:chain_id]}"
return name if caip2 == caip2_string
end

raise(ConfigurationError, "Unknown CAIP-2 network: #{caip2_string}")
end

def solana_chain?(chain_name)
chain_name.start_with?("solana")
end
end
end
Loading