Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions CAIPs/caip-358.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
---
caip: CAIP-358
title: Universal Payment Request Method
author: Luka Isailovic (@lukaisailovic), Derek Rein (@arein)
discussions-to: https://github.com/ChainAgnostic/CAIPs/pull/358
status: Draft
type: Standard
created: 2025-05-26
updated: 2025-05-26
requires: 2, 10, 19
replaces:
---


## Simple Summary

A standard for enabling one-interaction cryptocurrency payment experiences across wallets and dapps, allowing payment information to be transmitted in a single round-trip.

## Abstract

This CAIP standardizes a wallet <> dapp JSON-RPC method `wallet_pay` for more efficient communication about the purchase intent from the dapp to the wallet.
The method allows merchants to specify payment requirements enabling wallets to handle payment execution with minimal user interaction.

## Motivation

Current cryptocurrency payment experiences are either error-prone (manual transfers, address QR codes) or suboptimal, requiring multiple interactions from the user.
In addition to this, different payment providers implement different payment experiences, creating confusion.

Solutions like EIP-681 or `bitcoin:` url are ecosystem-specific and have not historically gotten sufficient support from the wallets. They tend to rely on a QR code scan as well, which means that they can't be batched as part of a connection-flow using protocols like WalletConnect.

By standardizing the payment experience on both the application side and the wallet side, we can reduce user errors during payment, providing the payment experience in as few clicks as possible and reducing the friction in crypto payments.

The method transmits all the acceptable payment requests so the wallet can pick the most optimal one based on the assets that user has in the account and the wallet's capabilities.

## Specification

### Method: `wallet_pay`

#### Request

```typescript
type Hex = `0x${string}`;

type PaymentOption = {
asset: string;
amount: Hex;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am rusty on this, but does amount need to be a Hex, or can it be a simpler string like "100.0"? Is it common for non-evm wallets to use hex's here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think hex is a pretty good convention. If you put "100.0" then everyone has to do the conversion. Pretty much all downstream RPCs and SDKs accept hex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw hex is extremely not standard in Solana

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can consider uint

Copy link
Copy Markdown

@jxom jxom Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we need to overthink it. Hex is a universal convention beyond crypto applications (ie. JS 0x69 resolves to 105 as a number, and we are referring to a JSON-RPC interface here) for numbers exceeding Number.MAX_SAFE_INTEGER.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is something that's going to be interpreted by the wallet is there a reason to not just go with JSON number? Seems like then it just becomes the wallets responsibility to format it correctly for the particular chain in the transaction. In this way language specific aspects around types (e.g. some need doubles, floats, uints, etc) also become simpler too.

recipient: string;
}

// JSON-RPC Request
type PayRequest = {
version: string;
orderId?: string;
acceptedPayments: PaymentOption[];
expiry: number;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful to also add an optional, human readable identifier like label / description here? Something similar to Solana Pay to help user identify payment request. Perhaps multiple of these can come through when they open up a wallet or come back online etc.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Solana Pay includes the following which are not specified here but have had proven use:

  • Label: UTF-8 string that describes the source of the transfer request (ex: a brand, store, app etc)
  • Message: UTF-8 string that is user facing to describe the nature of the request. This can help add context to wallets who display this request.
  • Memo: similar to the orderId above but more broad in specification

Additionally, on Solana you can embed arbitrary public keys in the message to allow for easy tracking or reference. This is the "reference" field and is extremely helpful for wallets/apps to be able to track client IDs

Copy link
Copy Markdown
Contributor

@kdenhartog kdenhartog Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have there been instances of this data being fraudulently submitted by a scam site for the purposes of tricking the user? It seems like due to instant settlements of crypto payments, there's more risk to submitting these payments to untrusted sites that have no intention of shipping goods or services once they receive payment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure there have been instances.. it's crypto so scams are bound to happen. I think we can fix this by having some sort of tls-like auth mechanism, or perhaps an integrity signature. I'm not sure if this would be defined in this caip or at a protocol level or as an additional capability to be adopted by teams where it makes sense

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My $0.02: there's not just scams in the form of "this data being fraudulently submitted by a scam site for the purposes of tricking the user" but there's also the problem of "fake stores" selling you iPhones that don't exist.

I believe trying to accomplish this with wallet_pay in its initial form is too much. But in a future iteration or addendum the problem of merchant fraud insurance must be covered.

FWIW: if wallet_pay is transmitted via WalletConnect then the transport provides some scam protection via WalletConnects "Verify API"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @arein
This should definitely be left out. By default wallet would be unable to display untrusted information, or at least it should never do that.

}

```

The application **MUST** include:

- At least one entry in the `acceptedPayments` array
- `expiry` timestamp for the payment request

The application **MAY** include:

- An `orderId` that uniquely identifies this payment request. If provided, `orderId` **MUST NOT** be longer than 128 characters.

When `orderId` is provided, it **MUST** be a string and implementations **SHOULD** ensure this ID is unique across their system to prevent collisions.
Copy link
Copy Markdown
Collaborator

@bumblefudge bumblefudge Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make more sense to require (or at least recommend) a UUID full-stop? human-readable or monotonic orderIds seem like they would wreak havok since it's not just "across their own system" that collisions could create problems (for, e.g., indexers)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think recommendation is fine, but I don't think it should be a requirement. This is dapp <> wallet RPC, so the orderId is not stored anywhere onchain


The `acceptedPayments` field **MUST** be an array of `PaymentOption` objects. Each element in the array represents a payment option that the wallet can choose from to complete the payment.

For `PaymentOption` options:

- The `recipient` field **MUST** be a valid [CAIP-10][] account ID.
- The `asset` field **MUST** follow the [CAIP-19][] standard.
- The `amount` field **MUST** be a hex-encoded string representing the amount of the asset to be transferred.
- The [CAIP-2][] chainId component in the [CAIP-19][] `asset` field **MUST** match the [CAIP-2][] chainId component of the [CAIP-10][] `recipient` account ID.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about the [CAIP-107][] namespace component? 😏

Copy link
Copy Markdown
Contributor Author

@lukaisailovic lukaisailovic Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wdym? chainId is namespace + reference (blockchain Id). Do you mean asset namespace in caip-19? That can't be used for anything, not sure why should we compare it to CAIP-2


The `expiry` field **MUST** be a UNIX timestamp (in seconds) after which the payment request is considered expired. Wallets **SHOULD** check this timestamp before processing the payment.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The `expiry` field **MUST** be a UNIX timestamp (in seconds) after which the payment request is considered expired. Wallets **SHOULD** check this timestamp before processing the payment.
The `expiry` field **MUST** be a UNIX timestamp (in seconds) after which the payment request is considered expired. Wallets **SHOULD** check this timestamp is not in the past before proposing or submitting a payment.


Request example:

```json
{
"version": "1.0.0",
"orderId": "order-123456",
"acceptedPayments": [
{
"recipient": "eip155:1:0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
"asset": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"amount": "0x5F5E100"
},
{
"recipient": "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
"asset": "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ/slip44:501",
"amount": "0x6F05B59D3B20000"
}
],
"expiry": 1709593200
}
```

#### Response

```typescript
type PayResult = {
version: string;
orderId?: string;
txid: string;
recipient: string;
asset: string;
amount: Hex;
}
```

The wallet's response MUST include:

- `txid` with the transaction identifier on the blockchain
- `recipient` that received the payment. It **MUST** be a valid [CAIP-10][] account ID.
- `asset` that was used for payment. It **MUST** follow the [CAIP-19][] standard.
- `amount` that was paid. It **MUST** be represented in hex string

If an `orderId` was provided in the original request, the response **MUST** include the same `orderId`.

`txid` **MUST** be a valid transaction identifier on the blockchain network specified in the asset's chain ID.

`recipient`, `asset`, and `amount` **MUST** match those specified in the selected direct payment option in the `acceptedPayments` array.


Example response:
```json
{
"version": "1.0.0",
"orderId": "order-123456",
"txid": "0x8a8c3e0b1b812182db4cabd81c9d6de78e549fa3bf3d505d6e1a2b25a15789ed",
"recipient": "eip155:1:0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
"asset": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"amount": "0x5F5E100"
}
```

#### Idempotency

The `wallet_pay` method **MUST** be idempotent for the same `orderId` when provided. This ensures robustness in case of connection failures or timeout scenarios.

Requirements when `orderId` is provided:

- If a payment with the same `orderId` has already been completed successfully, the wallet **MUST** return the original `PayResult` without executing a new payment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add more details as to the unique constraints for the orderId? is this per merchant app? Many chains don't have the ability to embed orderIds onchain so this information is likely held offchain.

On Solana, it's common to use the memo + reference key to uniquely identify the transaction as this data is onchain

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The orderId doesn't have to be stored onchain. It should be unique for each order. Merchant generated UUID should work just fine

- If a payment with the same `orderId` is currently pending, the wallet **SHOULD** return the result of the original payment attempt
Copy link
Copy Markdown

@jakubuid jakubuid Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify. What's the result of the original payment attempt? If the payment is currently pending the result is not yet available

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the wallet returned txid and you submit the payment with the same orderId, the same txid will be returned. The result doesn't specify the tx state (pending/success)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that in mind, it would likely be useful to the site to be able to reliably use this. Otherwise it could potentially lead to a UX divergence where a wallet that doesn't support this re-prompts for a new payment where as a wallet that does respond automatically with the TxID without a user interaction.

Therefore, I think we should upgrade this to a MUST rather than a SHOULD. Is there some reason you went with SHOULD here instead?

- If a payment with the same `orderId` has failed previously, the wallet **MAY** attempt the payment again or return the previous error
- Wallets **SHOULD** maintain payment status for completed transactions for at least 24 hours after completion
- If connection is lost during payment execution, dapps **MAY** retry the same request to query the payment status

When `orderId` is not provided:

- Each payment request **SHOULD** be treated as a new payment attempt
- Wallets **MAY** implement their own deduplication logic based on other request parameters (recipient, asset, amount, expiry)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Wallets **MAY** implement their own deduplication logic based on other request parameters (recipient, asset, amount, expiry)
- Wallets **MAY**, however, implement their own deduplication logic based on other request parameters (recipient, asset, amount, expiry) to prevent unintended duplicate payments for identical requests

- Dapps **SHOULD** include an `orderId` if they require guaranteed idempotency behavior

#### Error Handling

If the payment process fails, the wallet **MUST** return an appropriate error message:

```typescript
type PayError = {
code: number;
message: string;
data?: any;
}
```

The wallet **MUST** use one of the following error codes when the pay request fails:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might it be more future-proof to require an error message 800X or 80XX, or is the idea specifically that these are the only 4 valid errors to throw?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this version, there are. If there are other errors, or different use cases someone can write a new CAIP


- When user rejects the payment
- code = 8001
- message = "User rejected payment"
- When no matching assets are available in user's wallet
- code = 8002
- message = "No matching assets available"
- When the payment request has expired
- code = 8003
- message = "Payment request expired"
- When there are insufficient funds for the payment
- code = 8004
- message = "Insufficient funds"

If a wallet does not support the `wallet_pay` method, it **MUST** return an appropriate JSON-RPC error with code -32601 (Method not found).

Example error response:

```json
{
"id": 1,
"jsonrpc": "2.0",
"error": {
"code": 8001,
"message": "User rejected payment"
}
}
```

## Rationale
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should something about supporting Smart Contract payment flavors like Coinbase Commerce be added here?

Also should the be a discussion somewhere in the CAIP on how developers can reconcile payments? What happens when the wallet can send the transaction but then the internet cuts off and the user can no longer switch back to the dapp?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually removed smart contract interactions. It adds too much complexity for not a lot of benefits.

Currently the only reason its used is to identify payments, which can be done differently in a more efficient way. Also some implementations still use contracts with direct deposit into them


This specification evolved through multiple iterations to address fundamental usability issues in cryptocurrency payment flows. Initial exploration began as a CAIP alternative to EIP-681/Solana Pay, but analysis of existing payment service provider (PSP) implementations revealed significant friction in current user experiences.

Existing cryptocurrency payment flows typically require users to:

- Select a token type
- Choose a blockchain network
- Wait for address/QR code generation
- Complete the transfer manually

This multi-step process creates excessive friction, often requiring 4-6 user interactions for a simple payment.

The `wallet_pay` method addresses these limitations by:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for consideration: EIP-681 and Solana Pay are similar but chain specific. In order to remove chain selection on the PSP level it has to be chain agnostic by design


- Moving choice to the wallet rather than forcing merchants to pre-select payment methods, wallets can filter available options based on user account balances and preferences
- All payment options are transmitted in one request, eliminating the need for multiple user interactions
- The response includes transaction ID and execution details, providing immediate confirmation
- Can be batched with connection establishment, enabling "connect + pay" flows in protocols like WalletConnect

### Alternative Approaches Considered

An intermediate solution involved encoding multiple payment addresses in a single QR code, allowing merchants to present all payment options simultaneously.
However, this approach proved impractical for dapp implementations because:

- PSPs cannot determine which payment option was selected
- Monitoring requires polling up to 20+ addresses simultaneously
- No confirmation mechanism exists for payment completion

## Test Cases

TODO
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend adding both positive and negative cases here, it never hurts! it might also be a good place to stick end-to-end examples showing how it can be combined with x402, caip-25, wallet-standard, etc (i.e. end-to-end examples showing other layers, not just payment requests).


## Security Considerations

`wallet_pay` does not try to address various cases of merchant fraud that end-users are exposed to today.
Specifically it does not try to tackle merchant fraud insurance in case the sold good is not delivered.
It also does not attempt to provide dispute functionality. These present ideas for future work.

## Privacy Considerations

TODO

## Backwards Compatibility

TODO

<!-- All CAIPs that introduce backwards incompatibilities must include a section describing these incompatibilities and their severity. The CAIP must explain how the author proposes to deal with these incompatibilities. CAIP submissions without a sufficient backwards compatibility treatise may be rejected outright. -->

## References

- [CAIP-1] defines the CAIP document structure
- [EIP-681] is ethereum-specific prior art that also includes gas information in the URI

[CAIP-1]: https://ChainAgnostic.org/CAIPs/caip-1
[CAIP-2]: https://ChainAgnostic.org/CAIPs/caip-2
[CAIP-10]: https://ChainAgnostic.org/CAIPs/caip-10
[CAIP-19]: https://ChainAgnostic.org/CAIPs/caip-19
[EIP-681]: https://eips.ethereum.org/EIPS/eip-681

## Copyright

Copyright and related rights waived via [CC0](../LICENSE).