invoices: multi-crypto payment support
All checks were successful
Build and Deploy to Production / test (push) Successful in 18s
Build and Deploy to Production / build (push) Successful in 49s
Build and Deploy to Production / deploy (push) Successful in 31s

One asset per invoice from a registry (lib/crypto_asset.rb): BTC, LTC,
ETH, XMR, SOL, ALGO, USDT/USDC on ERC-20/TRC-20/BEP-20/Solana/Algorand.
Migration 003 generalizes the ltc_* columns to crypto_* + crypto_coin
(existing LTC invoices backfilled). QR payload adapts per asset: BIP21
amount URIs where supported, bare address for tokens with a network
hint on the PDF. Default addresses come from the CRYPTO_ADDRESSES
secret (JSON code=>address); LTC_ADDRESS still works as legacy.
This commit is contained in:
Sergei Poljanski 2026-07-03 02:01:57 +04:00
commit 0fbb6ee809
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
20 changed files with 410 additions and 143 deletions

64
lib/crypto_asset.rb Normal file
View file

@ -0,0 +1,64 @@
require 'json'
# Registry of crypto payment assets — one asset (coin, or token+chain for
# stablecoins) per invoice. Adding an asset is one entry here plus, optionally,
# a default payout address in the CRYPTO_ADDRESSES env (JSON, code => address).
#
# QR encoding is driven by scheme/amount_param:
# scheme + amount_param => scheme:<addr>?<param>=<amt> (wallet prefills amount)
# scheme only => scheme:<addr> (amount shown as text)
# neither => bare address (tokens/chains with no
# standard payment URI)
# coingecko is the id for the simple/price endpoint; chain variants of a
# stablecoin share the token's id.
module CryptoAsset
ASSETS = {
'BTC' => { name: 'Bitcoin', coingecko: 'bitcoin', scheme: 'bitcoin', amount_param: 'amount' },
'LTC' => { name: 'Litecoin', coingecko: 'litecoin', scheme: 'litecoin', amount_param: 'amount' },
'ETH' => { name: 'Ethereum', coingecko: 'ethereum', scheme: 'ethereum', amount_param: nil },
'XMR' => { name: 'Monero', coingecko: 'monero', scheme: 'monero', amount_param: 'tx_amount' },
'SOL' => { name: 'Solana', coingecko: 'solana', scheme: 'solana', amount_param: 'amount' },
'ALGO' => { name: 'Algorand', coingecko: 'algorand', scheme: nil, amount_param: nil },
'USDT-ERC20' => { name: 'Tether (Ethereum)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDT-TRC20' => { name: 'Tether (Tron)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDT-BEP20' => { name: 'Tether (BNB Chain)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDT-SOL' => { name: 'Tether (Solana)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDC-ERC20' => { name: 'USD Coin (Ethereum)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
'USDC-BEP20' => { name: 'USD Coin (BNB Chain)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
'USDC-SOL' => { name: 'USD Coin (Solana)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
'USDC-ALGO' => { name: 'USD Coin (Algorand)', coingecko: 'usd-coin', scheme: nil, amount_param: nil }
}.freeze
CODES = ASSETS.keys.freeze
module_function
def [](code)
ASSETS[code.to_s.upcase]
end
def valid?(code)
ASSETS.key?(code.to_s.upcase)
end
def name(code)
asset = self[code]
asset ? asset[:name] : code.to_s
end
# Default payout addresses for the new-invoice form. CRYPTO_ADDRESSES is a
# JSON object (code => address); the legacy LTC_ADDRESS env still fills LTC.
# Unknown codes are dropped so a typo in the env can't invent an asset.
def default_addresses
map = begin
JSON.parse(ENV['CRYPTO_ADDRESSES'].to_s)
rescue JSON::ParserError
{}
end
map = {} unless map.is_a?(Hash)
map = map.transform_keys { |k| k.to_s.upcase }
ltc = ENV['LTC_ADDRESS'].to_s
map['LTC'] = ltc unless ltc.empty? || map.key?('LTC')
map.slice(*CODES)
end
end