name. */ private const KNOWN = [ 'BTC' => 'Bitcoin', 'ETH' => 'Ethereum', 'USDT' => 'Tether', 'USDC' => 'USD Coin', 'BNB' => 'BNB', 'LTC' => 'Litecoin', 'TON' => 'Gram', 'TRX' => 'TRON', 'SOL' => 'Solana', 'XRP' => 'XRP', 'DOGE' => 'Dogecoin', 'MATIC' => 'Polygon', 'POL' => 'Polygon', 'DAI' => 'Dai', 'BCH' => 'Bitcoin Cash', 'ADA' => 'Cardano', 'DOT' => 'Polkadot', 'AVAX' => 'Avalanche', 'XMR' => 'Monero', 'SHIB' => 'Shiba Inu', 'BUSD' => 'Binance USD', 'TUSD' => 'TrueUSD', 'FRAX' => 'Frax', ]; /** Networks a coin may arrive on, longest first so that BEP2 does not match the end of BEP20. */ private const NETWORKS = [ 'ERC20', 'TRC20', 'BEP20', 'BEP2', 'POLYGON', 'ARBITRUM', 'OPTIMISM', 'AVAXC', 'ARBTM', 'MATIC', 'BASE', 'SOL', 'TON', 'BSC', 'ETH', 'TRX', ]; /** Short display names of the networks. */ private const NETWORK_LABELS = [ 'ERC20' => 'ERC-20', 'TRC20' => 'TRC-20', 'BEP20' => 'BEP-20', 'BEP2' => 'BEP-2', 'POLYGON' => 'Polygon', 'ARBITRUM' => 'Arbitrum', 'ARBTM' => 'Arbitrum', 'OPTIMISM' => 'Optimism', 'AVAXC' => 'Avalanche C', 'MATIC' => 'Polygon', 'BASE' => 'Base', 'SOL' => 'Solana', 'TON' => 'TON', 'BSC' => 'BNB Chain', 'ETH' => 'Ethereum', 'TRX' => 'TRON', ]; /** Long network names for the coin picker of the on-site payment. */ private const NETWORK_NAMES = [ 'ERC20' => 'Ethereum (ERC-20)', 'TRC20' => 'Tron (TRC-20)', 'BEP20' => 'BNB Smart Chain (BEP-20)', 'POLYGON' => 'Polygon', 'ARBITRUM' => 'Arbitrum', 'ARBTM' => 'Arbitrum', 'TON' => 'The Open Network', ]; /** * Tickers shown instead of the symbol id. Cryptoway's TON is the coin now named Gram * (GRAM). Display only: the id stored, sent and matched stays as Cryptoway sends it. */ private const DISPLAY_TICKERS = [ 'TON' => 'GRAM', ]; /** Coins whose mark ships in front.css (class cwbtn__coin-icon--); MATIC shares Polygon's. */ private const ICONS = [ 'USDT' => 'usdt', 'USDC' => 'usdc', 'BTC' => 'btc', 'ETH' => 'eth', 'BNB' => 'bnb', 'TRX' => 'trx', 'LTC' => 'ltc', 'POL' => 'pol', 'MATIC' => 'pol', 'TON' => 'ton', 'SOL' => 'sol', 'DOGE' => 'doge', ]; /** * What a chip of the on-site coin picker shows: the ticker, the short network name and * the key of the mark ('' when the plugin has none for this coin). * * @return array{ticker: string, network: string, icon: string} */ public static function chip( string $symbol ): array { $parts = self::split( $symbol ); return [ 'ticker' => self::display_ticker( $parts['coin'] ), 'network' => '' === $parts['network'] ? '' : ( self::NETWORK_LABELS[ $parts['network'] ] ?? $parts['network'] ), 'icon' => self::ICONS[ $parts['coin'] ] ?? '', ]; } /** USD-pegged stablecoins, longest first: their amounts are shown in cents. */ private const USD_STABLE = [ 'FDUSD', 'PYUSD', 'USDT', 'USDC', 'USDE', 'TUSD', 'DAI' ]; /** * Coins and networks offered for paying on the site when the list from Cryptoway is * not at hand (the invoice is created with this symbol_id, so Cryptoway answers with * a wallet address at once). The ids are the ones GET /symbol/ returned on 14.10.2026. */ public const ONSITE = [ 'USDTTRC20', 'USDTERC20', 'USDTBEP20', 'USDTPOLYGON', 'USDTARBTM', 'USDTTON', 'USDTSOL', 'USDCERC20', 'USDCSOL', 'BTC', 'ETH', 'ETHARBTM', 'LTC', 'TRX', 'BNBBEP20', 'POL', 'TON', 'SOL', 'DOGE', ]; public static function normalise( string $symbol ): string { return strtoupper( trim( $symbol ) ); } /** * Splits the symbol into a coin and a network: USDTBEP20 -> USDT + BEP20. * * @return array{coin:string,network:string} */ public static function split( string $symbol ): array { $symbol = self::normalise( $symbol ); if ( isset( self::KNOWN[ $symbol ] ) ) { return [ 'coin' => $symbol, 'network' => '' ]; } foreach ( self::NETWORKS as $network ) { if ( ! str_ends_with( $symbol, $network ) ) { continue; } $coin = substr( $symbol, 0, -strlen( $network ) ); $coin = rtrim( $coin, '-_. ' ); if ( '' !== $coin && isset( self::KNOWN[ $coin ] ) ) { return [ 'coin' => $coin, 'network' => $network ]; } } return [ 'coin' => $symbol, 'network' => '' ]; } /** * Coin and network as payment notes name them. Like split(), but a USD stablecoin the * list does not name (FDUSDBEP20) is split too, and so is any other coin on a token * standard (USDDTRC20). * * @return array{coin:string,network:string} */ private static function note_parts( string $symbol ): array { $parts = self::split( $symbol ); if ( '' !== $parts['network'] || in_array( $parts['coin'], self::USD_STABLE, true ) ) { return $parts; } foreach ( self::USD_STABLE as $coin ) { if ( ! str_starts_with( $parts['coin'], $coin ) ) { continue; } $network = ltrim( substr( $parts['coin'], strlen( $coin ) ), '-_. ' ); if ( in_array( $network, self::NETWORKS, true ) ) { return [ 'coin' => $coin, 'network' => $network ]; } } foreach ( [ 'ERC20', 'TRC20', 'BEP20' ] as $network ) { $coin = rtrim( substr( $parts['coin'], 0, -strlen( $network ) ), '-_. ' ); if ( str_ends_with( $parts['coin'], $network ) && 1 === preg_match( '/^[A-Z][A-Z0-9]+$/', $coin ) ) { return [ 'coin' => $coin, 'network' => $network ]; } } return $parts; } /** The ticker a coin id is shown with: GRAM for TON, the id itself otherwise. */ private static function display_ticker( string $coin ): string { return self::DISPLAY_TICKERS[ $coin ] ?? $coin; } /** The coin for a note or the payment screen: "USDT (BEP20)", "ETH". */ public static function note_label( string $symbol ): string { $parts = self::note_parts( $symbol ); $coin = self::display_ticker( $parts['coin'] ); return '' === $parts['network'] ? $coin : $coin . ' (' . $parts['network'] . ')'; } /** The coin alone, as notes name it: "USDT" for USDTBEP20, for an amount shown without its network. */ public static function note_coin( string $symbol ): string { return self::display_ticker( self::note_parts( $symbol )['coin'] ); } /** Whether the coin is a USD stablecoin, on any network. */ public static function is_usd_stable( string $symbol ): bool { return in_array( self::note_parts( $symbol )['coin'], self::USD_STABLE, true ); } /** The coin alone, without the network. */ public static function base( string $symbol ): string { return self::split( $symbol )['coin']; } /** Human-readable short network name. Empty when the symbol had no network. */ public static function network( string $symbol ): string { $network = self::split( $symbol )['network']; if ( '' === $network ) { return ''; } return self::NETWORK_LABELS[ $network ] ?? $network; } /** The symbol for display: "USDT BEP-20". */ public static function label( string $symbol ): string { $network = self::network( $symbol ); $coin = self::display_ticker( self::base( $symbol ) ); return '' === $network ? $coin : $coin . ' ' . $network; } /** * The coin picker label for the on-site payment: "USDT — Tron (TRC-20)", "BTC — Bitcoin", * "GRAM — The Open Network". */ public static function picker_label( string $symbol ): string { $parts = self::split( $symbol ); $ticker = self::display_ticker( $parts['coin'] ); if ( '' !== $parts['network'] ) { $network = self::NETWORK_NAMES[ $parts['network'] ] ?? self::NETWORK_LABELS[ $parts['network'] ] ?? $parts['network']; return $ticker . ' — ' . $network; } $name = match ( $parts['coin'] ) { 'TRX' => 'Tron', 'TON' => 'The Open Network', 'POL' => 'Polygon', default => self::KNOWN[ $parts['coin'] ] ?? '', }; return '' === $name || $name === $ticker ? $ticker : $ticker . ' — ' . $name; } /** Cached copy of the symbol list Cryptoway publishes (GET /symbol/). */ public const SYMBOLS_TRANSIENT = 'cryptoway_button_symbols'; /** * The symbol ids Cryptoway accepts right now, from GET /symbol/ (active, not virtual), * cached for a day; the built-in list when the API has not answered yet. * * @return string[] */ public static function available(): array { $cached = get_transient( self::SYMBOLS_TRANSIENT ); // Never fetched while a page is drawn: the scheduled check and saving the settings refresh it. return is_array( $cached ) && $cached ? $cached : self::ONSITE; } /** * Whether the scheduled check should fetch the list: it is missing or a day old, and the last * attempt did not fail within the hour. */ public static function needs_refresh(): bool { return ! is_array( get_transient( self::SYMBOLS_TRANSIENT ) ) && false === get_transient( self::SYMBOLS_TRANSIENT . '_tried' ) && Settings::keys_present(); } /** * Ask Cryptoway for its symbol list and cache the ids that can be paid with. * * @return array{ok: bool, count: int, error: string} */ public static function refresh_available(): array { if ( ! Settings::keys_present() ) { return [ 'ok' => false, 'count' => 0, 'error' => __( 'Project ID and Secret key are not both saved yet.', 'cryptoway-button' ) ]; } $api = ApiClient::from_settings(); $ids = []; for ( $page = 1; $page <= 3; $page++ ) { $response = $api->get_symbols( $page ); if ( is_wp_error( $response ) ) { // Not asked again by the scheduled check for an hour. set_transient( self::SYMBOLS_TRANSIENT . '_tried', 1, HOUR_IN_SECONDS ); return [ 'ok' => false, 'count' => 0, 'error' => $response->get_error_message() ]; } $items = self::items_of( $response ); foreach ( $items as $item ) { if ( ! is_array( $item ) || empty( $item['id'] ) || ! is_scalar( $item['id'] ) ) { continue; } if ( isset( $item['is_active'] ) && ! $item['is_active'] ) { continue; } if ( ! empty( $item['is_virtual'] ) ) { continue; } $ids[] = self::normalise( (string) $item['id'] ); } if ( count( $items ) < 100 ) { break; } } $ids = array_values( array_unique( $ids ) ); if ( [] === $ids ) { set_transient( self::SYMBOLS_TRANSIENT . '_tried', 1, HOUR_IN_SECONDS ); return [ 'ok' => false, 'count' => 0, 'error' => __( 'the answer does not contain any coins', 'cryptoway-button' ) ]; } set_transient( self::SYMBOLS_TRANSIENT, $ids, DAY_IN_SECONDS ); delete_transient( self::SYMBOLS_TRANSIENT . '_tried' ); return [ 'ok' => true, 'count' => count( $ids ), 'error' => '' ]; } /** The list inside a paginated answer, whatever the wrapper is called. */ private static function items_of( array $response ): array { foreach ( [ 'items', 'results', 'data', 'symbols' ] as $key ) { if ( isset( $response[ $key ] ) && is_array( $response[ $key ] ) ) { return $response[ $key ]; } } return array_is_list( $response ) ? $response : []; } /** * The coins offered by a button for paying on the site: the button's own list when * it names one, the list Cryptoway publishes otherwise. Unknown entries are kept as * typed, upper case: the site owner may know a symbol this list does not. * * @param string $own Comma-separated symbol ids from the button, or ''. * @return array symbol id => picker label. */ public static function picker( string $own = '' ): array { $symbols = []; foreach ( explode( ',', $own ) as $symbol ) { $symbol = preg_replace( '/[^A-Z0-9]/', '', self::normalise( $symbol ) ); if ( '' !== $symbol ) { $symbols[] = $symbol; } } if ( [] === $symbols ) { $symbols = Settings::enabled_coins(); } // The usual choice first: USDT on its networks, then the big coins, then the rest as listed. $rank = array_flip( self::PICKER_ORDER ); $symbols = array_values( array_unique( $symbols ) ); usort( $symbols, static function ( string $a, string $b ) use ( $rank ): int { return ( $rank[ $a ] ?? 999 ) <=> ( $rank[ $b ] ?? 999 ) ?: strcmp( $a, $b ); } ); $out = []; foreach ( $symbols as $symbol ) { $out[ $symbol ] = self::picker_label( $symbol ); } return $out; } /** The order of the coin picker; anything else follows alphabetically. */ private const PICKER_ORDER = [ 'USDTTRC20', 'USDTBEP20', 'USDTERC20', 'USDTPOLYGON', 'USDTARBTM', 'USDTTON', 'USDTSOL', 'USDCERC20', 'USDCSOL', 'BTC', 'ETH', 'ETHARBTM', 'BNBBEP20', 'TRX', 'LTC', 'SOL', 'DOGE', 'POL', 'TON', ]; }