Build the payment URI a wallet understands. EIP-681, BIP-21, Solana Pay. Zero dependencies, no network calls, about 90 lines.
This is what DarkRoute puts in the QR on an order page, extracted because the interesting part is not the encoder, it is knowing when to refuse.
import { payUri } from "@darkroute/payment-uri";
// native ETH: the value rides in the URI, in wei
payUri({ chain: "eth", chainId: 1, address: "0x1111…1111", amount: 0.1,
amountUnits: "100000000000000000", contractAddress: null, memo: null });
// { uri: "ethereum:0x1111…1111@1?value=100000000000000000", scheme: "ethereum", exact: true }
// a token: a transfer() call on the token, not a send to the token
payUri({ chain: "base", chainId: 8453, address: "0x1111…1111", amount: 25,
amountUnits: "25000000", contractAddress: "0x8335…2913", memo: null });
// { uri: "ethereum:0x8335…2913@8453/transfer?address=0x1111…1111&uint256=25000000", … }
// bitcoin: whole coins, never base units
payUri({ chain: "btc", chainId: null, address: "bc1q…", amount: 0.00412, … });
// { uri: "bitcoin:bc1q…?amount=0.00412", scheme: "bitcoin", exact: true }It returns null rather than something plausible in two cases. Both are the point of the library.
A payment that needs a memo gets no URI. On TON, XRP, Cosmos chains and most exchange deposits, a transfer without its destination tag is a lost transfer, not a late one. Wallet support for carrying that tag inside a payment URI is uneven enough that you would be trusting somebody else's parser with your user's money. Show the address and the tag separately and let them paste both.
A chain you cannot name gets no URI. The chain id is an argument, not a lookup, so this library
never has an opinion about which network your "eth" means. Pass chainId: null and you get
null back, because a guessed chain id sends funds to the right address on the wrong network.
A URI that is subtly wrong is worse than no URI at all. The person scans it, the wallet fills in something plausible, and the mistake is only visible after the money has moved.
- No QR rendering. It returns a string. Feed it to whichever QR library you already have.
- No validation of the address itself. Shape only, and only enough to avoid emitting garbage. For real per-chain address checks see @darkroute/address-rules.
- No SPL tokens. Solana Pay needs the mint address, and most registries hand you an asset id instead, so the token case is left alone rather than half guessed.
- No amount maths. You pass both the decimal amount and the base-unit string; converting between them is your rounding decision, not ours.
npm i @darkroute/payment-uriOr copy src/index.ts. It is one file with no imports, and that is deliberate.
npm testEight tests, node --test, no framework. The refusals have more coverage than the encoder.
DarkRoute quotes a swap, prints the fee before you deposit, and hands you a one-time deposit address. Retyping the amount from that page is where short deposits came from, and a short deposit comes back as a refund minus network cost. Putting the amount in the QR removed the retyping. The refusals above are what stops that convenience from becoming a new way to lose a deposit.
MIT.