'', 'amount' => self::FREE, 'amounts' => '', 'min' => '', 'currency' => '', 'pay' => self::PAY_PAGE, 'label' => '', 'title' => '', 'after' => self::AFTER_MESSAGE, 'message' => '', 'redirect' => '', 'access' => '', 'fields' => '', 'coins' => '', 'custom' => 'yes', 'qr' => 'yes', 'amount_label' => '', 'other_label' => '', 'coin_label' => '', 'name_label' => '', 'email_label' => '', 'message_label' => '', 'color' => '', 'text_color' => '', 'radius' => '', 'width' => '', 'size' => '', 'frame' => '', 'skin' => '', 'className' => '', 'align' => '', ]; /** Query argument the plugin's own front-end requests carry. */ public const QUERY = 'cryptoway-button'; /** Fingerprints of paid content remembered from buttons drawn outside post content: key => md5. */ public const PAID_INDEX = 'cryptoway_button_paid_index'; /** Prefix of the options that hold that content, one per button (the raw markup, rendered for a payer). */ public const PAID_PREFIX = 'cryptoway_button_paid_'; /** How many times each button was drawn in this request: element ids stay unique on a page. */ private static array $drawn = []; /** Paid buttons found per post in this request: post id => [ key => [ kind, raw, terms ] ]. */ private static array $located = []; /** The paid content each key was first drawn with on a post in this request: post|key => checksum. */ private static array $contents = []; private function __construct() {} // ------------------------------------------------------------------ parameters /** * Clean parameters with defaults, whatever the source (block attributes, shortcode). * * @return array */ public static function normalise( array $atts ): array { $cfg = []; // The block names multi-word parameters in camelCase; the shortcode with an underscore. foreach ( array_keys( self::DEFAULTS ) as $key ) { if ( str_contains( $key, '_' ) ) { $camel = lcfirst( str_replace( '_', '', ucwords( $key, '_' ) ) ); if ( isset( $atts[ $camel ] ) && ! isset( $atts[ $key ] ) ) { $atts[ $key ] = $atts[ $camel ]; } } } foreach ( self::DEFAULTS as $key => $default ) { $value = $atts[ $key ] ?? $default; $cfg[ $key ] = is_scalar( $value ) ? trim( (string) $value ) : $default; } // The button's own id (given once in the editor, or id="" in the shortcode): payments are tied // to it, so changing the label, the price or the texts keeps access for those who paid. $cfg['bid'] = substr( (string) preg_replace( '/[^a-z0-9_-]/', '', strtolower( $cfg['bid'] ) ), 0, 40 ); // Amount: a positive decimal with up to two places, or "free". $amount = str_replace( ',', '.', strtolower( $cfg['amount'] ) ); if ( self::FREE !== $amount ) { $amount = self::clean_amount( $amount ); $cfg['amount'] = '' === $amount ? self::FREE : $amount; } else { $cfg['amount'] = self::FREE; } // Preset amounts for a free-amount button: "5,10,25". $presets = []; foreach ( preg_split( '/[\s,;]+/', $cfg['amounts'] ) ?: [] as $preset ) { $preset = self::clean_amount( str_replace( ',', '.', $preset ) ); if ( '' !== $preset ) { $presets[] = $preset; } } $cfg['amounts'] = implode( ',', array_slice( array_unique( $presets ), 0, 6 ) ); // Idempotent on purpose: the hash of a button is computed from these values more than once. $min = self::clean_amount( str_replace( ',', '.', $cfg['min'] ) ); $cfg['min'] = '' !== $min ? $min : self::clean_amount( self::DEFAULT_MIN ); $currency = strtoupper( $cfg['currency'] ); $cfg['currency'] = in_array( $currency, Settings::CURRENCIES, true ) ? $currency : Settings::currency(); $cfg['pay'] = self::PAY_ONSITE === strtolower( $cfg['pay'] ) ? self::PAY_ONSITE : self::PAY_PAGE; $cfg['after'] = in_array( strtolower( $cfg['after'] ), [ self::AFTER_REDIRECT, self::AFTER_CONTENT ], true ) ? strtolower( $cfg['after'] ) : self::AFTER_MESSAGE; // How long paid content stays open to the payer: 1-3650 days, 30 by default. $cfg['access'] = '' !== $cfg['access'] && is_numeric( $cfg['access'] ) ? (string) max( 1, min( 3650, (int) $cfg['access'] ) ) : (string) self::ACCESS_DAYS; $cfg['label'] = sanitize_text_field( $cfg['label'] ); $cfg['title'] = sanitize_text_field( $cfg['title'] ); $cfg['message'] = sanitize_textarea_field( $cfg['message'] ); // The thank-you page: a path on this site or a full URL. $redirect = $cfg['redirect']; if ( '' !== $redirect && str_starts_with( $redirect, '/' ) ) { $redirect = home_url( $redirect ); } $redirect = esc_url_raw( $redirect ); $cfg['redirect'] = '' !== $redirect && wp_http_validate_url( $redirect ) ? $redirect : ''; if ( self::AFTER_REDIRECT === $cfg['after'] && '' === $cfg['redirect'] ) { $cfg['after'] = self::AFTER_MESSAGE; } $fields = []; foreach ( explode( ',', strtolower( $cfg['fields'] ) ) as $field ) { $field = trim( $field ); if ( in_array( $field, [ 'name', 'email', 'message' ], true ) ) { $fields[] = $field; } } $cfg['fields'] = implode( ',', array_unique( $fields ) ); // Paid content is reached by e-mail as well (the link that opens it on any device), so the // e-mail is always asked for; the big services (Stripe Checkout) require it the same way. if ( self::AFTER_CONTENT === $cfg['after'] && ! in_array( 'email', $fields, true ) ) { $cfg['fields'] = '' === $cfg['fields'] ? 'email' : $cfg['fields'] . ',email'; } $cfg['coins'] = trim( strtoupper( (string) preg_replace( '/[^A-Za-z0-9,]/', '', $cfg['coins'] ) ), ',' ); // The QR code next to the address (payment on the page); "no" hides it, anything else shows it. $cfg['qr'] = 'no' === strtolower( $cfg['qr'] ) ? 'no' : 'yes'; // Whether a free-amount button also takes an amount the visitor types; without presets it must. $cfg['custom'] = 'no' === strtolower( $cfg['custom'] ) && '' !== $cfg['amounts'] ? 'no' : 'yes'; // Texts edited in the block: plain text, entities decoded (the editor stores them encoded). foreach ( [ 'amount_label', 'other_label', 'coin_label', 'name_label', 'email_label', 'message_label' ] as $key ) { $cfg[ $key ] = sanitize_text_field( wp_specialchars_decode( wp_strip_all_tags( $cfg[ $key ] ), ENT_QUOTES ) ); } $cfg['label'] = sanitize_text_field( wp_specialchars_decode( wp_strip_all_tags( $cfg['label'] ), ENT_QUOTES ) ); // Looks: a hex colour for the button and its text, a corner radius in pixels, full width. $cfg['color'] = (string) sanitize_hex_color( $cfg['color'] ); $cfg['text_color'] = (string) sanitize_hex_color( $cfg['text_color'] ); $cfg['radius'] = '' !== $cfg['radius'] && is_numeric( $cfg['radius'] ) ? (string) max( 0, min( 60, (int) $cfg['radius'] ) ) : ''; $cfg['width'] = 'full' === strtolower( $cfg['width'] ) ? 'full' : ''; $cfg['size'] = in_array( strtolower( $cfg['size'] ), [ 'small', 'large' ], true ) ? strtolower( $cfg['size'] ) : ''; // Ready-made looks shipped in front.css: a dark card, or the Cryptoway card. A skin is a card // already, so the plain frame is dropped with it (the two together looked broken, 2026-10-12). $cfg['skin'] = in_array( strtolower( $cfg['skin'] ), [ 'dark', 'cryptoway' ], true ) ? strtolower( $cfg['skin'] ) : ''; $cfg['frame'] = '' === $cfg['skin'] && 'yes' === strtolower( $cfg['frame'] ) ? 'yes' : ''; $cfg['className'] = trim( (string) preg_replace( '/[^A-Za-z0-9_\- ]/', '', $cfg['className'] ) ); $cfg['align'] = in_array( $cfg['align'], [ 'left', 'center', 'right' ], true ) ? $cfg['align'] : ''; return $cfg; } /** * Shortcode attributes as button parameters: id="" is the button's own id, class="" its CSS * class, and text between the tags makes it a paid-content button. * * @param array $atts Attributes as WordPress parsed them. */ public static function shortcode_params( array $atts, bool $encloses_content ): array { if ( isset( $atts['id'] ) && ! isset( $atts['bid'] ) ) { $atts['bid'] = $atts['id']; } if ( isset( $atts['class'] ) && ! isset( $atts['className'] ) ) { $atts['className'] = $atts['class']; } if ( $encloses_content ) { $atts['after'] = self::AFTER_CONTENT; } return shortcode_atts( self::DEFAULTS, $atts, 'cryptoway' ); } /** A positive decimal with at most two places, or ''. */ public static function clean_amount( string $value ): string { $value = trim( $value ); if ( ! preg_match( '/^\d{1,9}(?:\.\d{1,2})?$/', $value ) ) { return ''; } $value = number_format( (float) $value, 2, '.', '' ); return (float) $value > 0 ? $value : ''; } /** The coins a button offers for paying on the site. */ public static function coins_of( string $own ): array { return Coins::picker( $own ); } public static function is_fixed( array $cfg ): bool { return self::FREE !== $cfg['amount']; } /** The default button label: "Pay 50.00 USD" or "Pay with crypto". */ public static function label( array $cfg ): string { if ( '' !== $cfg['label'] ) { return $cfg['label']; } if ( self::is_fixed( $cfg ) ) { /* translators: %s: amount with currency, e.g. "50.00 USD" */ return sprintf( __( 'Pay %s with crypto', 'cryptoway-button' ), Amounts::fiat( $cfg['amount'], $cfg['currency'] ) ); } return __( 'Pay with crypto', 'cryptoway-button' ); } // ------------------------------------------------------------------ signed configuration /** The keys a form carries; everything the server must not take from the visitor. */ private const SIGNED = [ 'bid', 'amount', 'amounts', 'custom', 'min', 'currency', 'pay', 'label', 'title', 'after', 'message', 'redirect', 'access', 'fields', 'coins', 'post' ]; /** A fingerprint of the button's parameters: the same parameters give the same hash. */ public static function hash( array $cfg ): string { return substr( md5( self::pack( $cfg ) ), 0, 12 ); } /** * The identity of a button, which payments are tied to: its own id when it has one, the hash * of its parameters otherwise (a shortcode without id="", a block saved before ids existed). */ public static function key( array $cfg ): string { return '' !== (string) ( $cfg['bid'] ?? '' ) ? (string) $cfg['bid'] : self::hash( $cfg ); } /** * Every key the payments of a button may carry: its id, and the hash it had before it got one (a * block saved without an id is given one when it is next opened in the editor; its payers keep * their access as long as nothing else was changed). * * @return string[] */ public static function keys( array $cfg ): array { if ( '' === (string) ( $cfg['bid'] ?? '' ) ) { return [ self::hash( $cfg ) ]; } return [ (string) $cfg['bid'], self::hash( array_merge( $cfg, [ 'bid' => '' ] ) ) ]; } /** A fingerprint of what a paid button sells at: the price and the access period. */ private static function terms( array $cfg ): string { return md5( implode( '|', [ $cfg['amount'], $cfg['amounts'], $cfg['custom'], $cfg['min'], $cfg['currency'], $cfg['access'] ] ) ); } /** * Whether a signed form of a paid button still sells at the terms its button has now. A form kept * from before the owner changed the price or the access period (a saved page, a page cache) must * not open the content at the old terms. True when the button is not found: nothing to compare. */ public static function is_current( array $cfg ): bool { if ( self::AFTER_CONTENT !== $cfg['after'] ) { return true; } $found = self::find_paid( self::key( $cfg ), (int) ( $cfg['post'] ?? 0 ) ); return null === $found || '' === $found['terms'] || hash_equals( $found['terms'], self::terms( $cfg ) ); } /** The configuration as the form carries it. */ public static function pack( array $cfg ): string { $signed = []; foreach ( self::SIGNED as $key ) { // Without an id of its own a button packs as it did before ids existed: its hash, and the // payments tied to it, stay the same. if ( 'bid' === $key && '' === (string) ( $cfg['bid'] ?? '' ) ) { continue; } $signed[ $key ] = (string) ( $cfg[ $key ] ?? '' ); } return rtrim( strtr( base64_encode( (string) wp_json_encode( $signed ) ), '+/', '-_' ), '=' ); } public static function sign( string $packed ): string { return hash_hmac( 'sha256', $packed, wp_salt( 'nonce' ) . '|cryptoway-button' ); } /** * The configuration a form sent, when its signature is intact. * * @return array|null */ public static function unpack( string $packed, string $signature ): ?array { if ( '' === $packed || '' === $signature || ! hash_equals( self::sign( $packed ), $signature ) ) { return null; } // Decoded only after the signature matched: this is the plugin's own packed configuration. $json = base64_decode( strtr( $packed, '-_', '+/' ), true ); $cfg = false !== $json ? json_decode( $json, true ) : null; if ( ! is_array( $cfg ) ) { return null; } $clean = self::normalise( $cfg ); $clean['post'] = (string) (int) ( $cfg['post'] ?? 0 ); return $clean; } // ------------------------------------------------------------------ rendering /** * The button as a visitor sees it, or the state of the payment the visitor just came * back from. * * @param array $atts Block attributes or shortcode attributes. * @param int $post_id The post the button is on; the current post when 0. * @param string $block The block wrapper attributes (get_block_wrapper_attributes()), '' for a shortcode. * @param string $content The paid content as rendered HTML, for a payer's done page; '' to have it rendered from the post * (the block and the shortcode never draw it with the page). * @param array|null $paid The paid content as stored: [ 'kind' => 'blocks'|'text', 'raw' => markup ], to find it later * when the button is not in post content (a template, a widget). */ public static function render( array $atts, int $post_id = 0, string $block = '', string $content = '', ?array $paid = null ): string { $cfg = self::normalise( $atts ); $cfg['post'] = (string) ( $post_id > 0 ? $post_id : (int) get_the_ID() ); $key = self::key( $cfg ); if ( ! Settings::keys_present() ) { if ( current_user_can( 'manage_options' ) ) { return self::outer( $block, '

' . sprintf( /* translators: %s: link to the settings screen */ esc_html__( 'Cryptoway Payment: add the Project ID and Secret key in %s to show this button.', 'cryptoway-button' ), '' . esc_html__( 'Settings', 'cryptoway-button' ) . '' ) . '

' ); } return ''; } self::enqueue(); $note = ''; if ( self::AFTER_CONTENT === $cfg['after'] && null !== $paid ) { $paid['terms'] = self::terms( $cfg ); self::remember_paid( $key, (int) $cfg['post'], $paid ); $note = self::duplicate_note( $key, (int) $cfg['post'], $paid ); } $uid = self::uid( $key ); $returned = self::returned_payment( self::keys( $cfg ), (int) $cfg['post'] ); // A block's own CSS class is on the outer wrapper WordPress builds; a shortcode's goes here. $class = '' === $block ? $cfg['className'] : ''; $inner = $returned ? self::wrapper( $cfg, $key, $uid, $class, self::panel_html( $returned, $content ) ) : self::wrapper( $cfg, $key, $uid, $class, self::form_html( $cfg, $key, $uid ) ); return self::outer( $block, $note . $inner ); } /** * For an editor only: a second paid button on the page with the same key (two shortcodes with the * same id, or with the same settings and no id) but other content. Its payers would get the first * one's content. * * @param array{kind: string, raw: string} $paid */ private static function duplicate_note( string $key, int $post_id, array $paid ): string { $slot = $post_id . '|' . $key; $sum = md5( $paid['raw'] ); self::$contents[ $slot ] ??= $sum; if ( self::$contents[ $slot ] === $sum || ! current_user_can( 'edit_post', $post_id ) ) { return ''; } return '

' . esc_html__( 'Only editors see this: another paid button on this page has the same id (or the same settings and no id), so payers of this one would get the other one\'s content. Give each paid button its own id.', 'cryptoway-button' ) . '

'; } /** A block gets the wrapper WordPress builds for it (alignment and custom classes); a shortcode does not. */ private static function outer( string $block, string $inner ): string { return '' === $block ? $inner : '
' . $inner . '
'; } /** * Scripts and styles for the front end. The addresses and texts the script uses go with the script, * once: a button's own markup carries only its parameters, since a post's author can write markup. */ private static function enqueue(): void { static $site_data = false; // A block theme draws the page before wp_enqueue_scripts: the data needs the script registered. if ( ! wp_script_is( 'cryptoway-button', 'registered' ) ) { Plugin::get_instance()->register_front_assets(); } wp_enqueue_style( 'cryptoway-button' ); wp_enqueue_script( 'cryptoway-button' ); if ( ! $site_data ) { $site_data = wp_add_inline_script( 'cryptoway-button', 'window.cryptowayButtonSite = ' . wp_json_encode( [ 'status' => esc_url_raw( rest_url( Rest::NAMESPACE . '/status/' ) ), 'fresh' => esc_url_raw( self::nonce_url() ), 'i18n' => self::i18n(), ] ) . ';', 'before' ); } } /** The id suffix of this drawing of a button: the key, numbered from the second time on. */ private static function uid( string $key ): string { self::$drawn[ $key ] = ( self::$drawn[ $key ] ?? 0 ) + 1; return 1 === self::$drawn[ $key ] ? $key : $key . '-' . self::$drawn[ $key ]; } private static function wrapper( array $cfg, string $key, string $uid, string $class, string $inner ): string { $classes = [ 'cwbtn', 'cwbtn--' . $cfg['pay'] ]; // Centred content for a centred block; left and right are the theme's floats on the outer wrapper. if ( 'center' === $cfg['align'] ) { $classes[] = 'cwbtn--align-center'; } if ( 'full' === $cfg['width'] ) { $classes[] = 'cwbtn--full'; } if ( '' !== $cfg['size'] ) { $classes[] = 'cwbtn--size-' . $cfg['size']; } if ( 'yes' === $cfg['frame'] ) { $classes[] = 'cwbtn--framed'; } if ( '' !== $cfg['skin'] ) { $classes[] = 'cwbtn--skin-' . $cfg['skin']; } foreach ( preg_split( '/\s+/', $class ) ?: [] as $own ) { $own = sanitize_html_class( $own ); if ( '' !== $own ) { $classes[] = $own; } } // Only what the button sets is written: the theme's own button style stays otherwise. $style = []; if ( '' !== $cfg['color'] ) { $classes[] = 'cwbtn--colored'; $style[] = '--cwbtn-accent:' . $cfg['color']; } if ( '' !== $cfg['text_color'] ) { $classes[] = 'cwbtn--colored'; $style[] = '--cwbtn-accent-text:' . $cfg['text_color']; } if ( '' !== $cfg['radius'] ) { $classes[] = 'cwbtn--rounded'; $style[] = '--cwbtn-radius:' . $cfg['radius'] . 'px'; } $classes = array_unique( $classes ); $data = [ 'key' => $key, 'post' => (int) $cfg['post'], 'pay' => $cfg['pay'], 'qr' => 'yes' === $cfg['qr'], 'currency' => $cfg['currency'], 'min' => $cfg['min'], ]; return sprintf( '
%4$s
', esc_attr( implode( ' ', $classes ) ), esc_attr( $uid ), esc_attr( (string) wp_json_encode( $data ) ), $inner, $style ? ' style="' . esc_attr( implode( ';', $style ) ) . '"' : '' ); } /** The form: amount, coin, payer fields and the button. Works without JavaScript. */ private static function form_html( array $cfg, string $key, string $uid ): string { $packed = self::pack( $cfg ); $error = self::error_for( $key ); $html = '
'; $html .= ''; $html .= ''; // By hand rather than wp_nonce_field(): a page may hold several buttons, and ids must not repeat. $html .= ''; // Honeypot: a field a person never sees and a script fills in. $html .= ''; if ( ! self::is_fixed( $cfg ) ) { $html .= self::amount_html( $cfg, $uid ); } if ( self::PAY_ONSITE === $cfg['pay'] ) { $html .= self::coins_html( $cfg, $uid ); } $html .= self::fields_html( $cfg, $uid ); $html .= '

'; $html .= ''; $html .= '
'; if ( self::AFTER_CONTENT === $cfg['after'] ) { $html .= self::recover_html( $key, $uid, $packed ); } $html .= ''; return $html; } /** Preset chips and the custom amount field of a free-amount button. */ private static function amount_html( array $cfg, string $uid ): string { $presets = '' !== $cfg['amounts'] ? explode( ',', $cfg['amounts'] ) : []; $custom = 'no' !== $cfg['custom'] || ! $presets; $html = '
' . esc_html( '' !== $cfg['amount_label'] ? $cfg['amount_label'] : __( 'Amount', 'cryptoway-button' ) ) . ''; if ( $presets ) { $html .= '
'; foreach ( $presets as $i => $preset ) { $html .= sprintf( '', esc_attr( $preset ), 0 === $i ? ' checked' : '', esc_html( Amounts::fiat( $preset, $cfg['currency'] ) ) ); } if ( $custom ) { $html .= ''; } $html .= '
'; } if ( ! $custom ) { return $html . '
'; } $html .= sprintf( '

' . '%5$s' . '%6$s

', $presets ? ' data-cwbtn-custom' : '', esc_attr( $uid ), esc_html__( 'Amount', 'cryptoway-button' ), esc_attr( $cfg['min'] ), esc_html( $cfg['currency'] ), /* translators: %s: smallest amount with currency */ esc_html( sprintf( __( 'From %s', 'cryptoway-button' ), Amounts::fiat( $cfg['min'], $cfg['currency'] ) ) ) ); $html .= ''; return $html; } /** * The coin picker of the on-site payment: a short list, "USDT · TRC-20", "BTC", "GRAM". A * native select, so it works without JavaScript; front.js draws its own list over it with * the coins' marks (the browser painted the native list unreadably on the dark cards, * owner 2026-10-12) from the data attributes of the options. */ private static function coins_html( array $cfg, string $uid ): string { $id = 'cwbtn-coin-' . $uid; $html = '

'; $html .= '

'; } /** Optional payer fields the button asks for. */ private static function fields_html( array $cfg, string $uid ): string { if ( '' === $cfg['fields'] ) { return ''; } $html = ''; foreach ( explode( ',', $cfg['fields'] ) as $field ) { $id = 'cwbtn-' . $field . '-' . $uid; $html .= match ( $field ) { 'name' => '

' . '

', 'email' => self::email_field_html( $cfg, $id ), default => '

' . '

', }; } return $html; } /** * The e-mail row. For paid content it is required (the access link goes there) and * prefilled for a signed-in visitor, as the big checkouts do. */ private static function email_field_html( array $cfg, string $id ): string { $required = self::AFTER_CONTENT === $cfg['after']; $value = is_user_logged_in() ? (string) wp_get_current_user()->user_email : ''; $label = '' !== $cfg['email_label'] ? $cfg['email_label'] : __( 'Your e-mail', 'cryptoway-button' ); $html = '

'; $html .= ''; if ( $required ) { $html .= '' . esc_html__( 'The link that opens the paid content on any device goes there.', 'cryptoway-button' ) . ''; } return $html . '

'; } /** * "Already paid?" under a paid-content button: the visitor types the e-mail used at payment * and the access link is mailed again. The same signed parameters tell the server which * button it is. A
element, so it works without JavaScript. */ private static function recover_html( string $key, string $uid, string $packed ): string { $sent = self::recover_sent( $key ); $id = 'cwbtn-recover-' . $uid; $html = '
'; $html .= '' . esc_html__( 'Already paid? Get your access link', 'cryptoway-button' ) . ''; $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= '

'; $html .= ''; $html .= '

'; $html .= '

' . esc_html( $sent ? Checkout::recover_text() : '' ) . '

'; $html .= '
'; return $html; } /** * The panel a visitor sees after starting a payment: its state as text, and for the * on-site payment the address and the amount in coin. JavaScript redraws it from the * state and keeps it fresh; this markup is what a browser without JavaScript gets. */ public static function panel_html( Payment $payment, string $content = '' ): string { $state = self::state( $payment ); $served = $state; // The paid content is printed below as markup; the script never takes HTML from an attribute. unset( $served['content'] ); $html = '
'; $html .= '

' . esc_html( $state['text'] ) . '

'; // Paid content for the payer who came back: the page's own rendered blocks (already escaped // by their renderers), never anything from the request. if ( $state['unlocked'] ) { $html .= '
' . ( '' !== $content ? $content : $state['content'] ) . '
'; } if ( '' !== $state['address'] && $state['send'] ) { if ( '' !== $state['to_send'] ) { $html .= '

' . esc_html( sprintf( /* translators: 1: amount in coin, 2: coin with network */ __( 'Send exactly %1$s %2$s to this address:', 'cryptoway-button' ), $state['to_send'], $state['coin'] ) ) . '

'; } $html .= '

' . esc_html( $state['address'] ) . '

'; } if ( '' !== $state['pay_url'] && $state['send'] && self::PAY_PAGE === $payment->mode() ) { $html .= '

' . esc_html__( 'Open the payment page', 'cryptoway-button' ) . '

'; } if ( ! $state['final'] ) { $html .= '

' . esc_html__( 'Reload this page to see the current status.', 'cryptoway-button' ) . '

'; } if ( $state['again'] ) { $html .= '
' . '
'; } $html .= '
'; return $html; } /** * What the visitor is told about a payment, for the panel and the status endpoint. * Nothing here is the owner's business: no history, no payer data. */ public static function state( Payment $payment ): array { $status = $payment->status(); $manager = new PaymentManager( $payment ); $amounts = $manager->shown_amounts(); $symbol = $payment->symbol(); $to_pay = Amounts::to_pay( $symbol, $payment->meta( Payment::META_TARGET_VALUE ) ); $final = in_array( $status, [ Payment::STATUS_PAID, Payment::STATUS_EXPIRED, Payment::STATUS_CANCELLED, Payment::STATUS_LATE, Payment::STATUS_AML, Payment::STATUS_FAILED, Payment::STATUS_HOLD ], true ); $closed = in_array( $status, [ Payment::STATUS_EXPIRED, Payment::STATUS_CANCELLED, Payment::STATUS_FAILED ], true ); $payable = $payment->link_is_alive(); $unlocked = self::is_unlocked( $payment ); // An underpaid payment asks for what is missing, not the whole amount again: a payer who // copied the full amount would pay twice (tech review 2026-10-12). // When the remainder is unknown, no amount is named at all. $to_send = Payment::STATUS_UNDERPAID === $status ? $amounts['rest'] : $to_pay; // Every wording here can be replaced on the settings screen (Payment statuses). $text = match ( $status ) { Payment::STATUS_PAID => '' !== $payment->meta( Payment::META_MESSAGE ) ? $payment->meta( Payment::META_MESSAGE ) : Texts::get( 'paid' ), Payment::STATUS_CONFIRMING => Texts::get( 'confirming' ), Payment::STATUS_UNDERPAID => $payable ? ( '' !== $amounts['rest'] ? Texts::line( 'underpaid', $amounts['paid'], $amounts['invoiced'], $amounts['coin'], $amounts['rest'] ) : Texts::get( 'underpaid_unknown' ) ) : Texts::get( 'short_late' ), Payment::STATUS_EXPIRED => Texts::get( 'expired' ), // Late with a remainder: the visitor underpaid and the time ran out (live 2026-10-12). Payment::STATUS_LATE => '' !== $amounts['rest'] ? Texts::get( 'short_late' ) : Texts::get( 'late' ), Payment::STATUS_AML => Texts::get( 'aml' ), Payment::STATUS_CANCELLED => Texts::get( 'cancelled' ), Payment::STATUS_HOLD => Texts::get( 'hold' ), Payment::STATUS_FAILED => Texts::get( 'failed' ), // The last minute: the address is no longer offered, the invoice is being closed. default => ! $payable && Payment::STATUS_PENDING === $status ? __( 'The time to pay is up. Checking the payment…', 'cryptoway-button' ) : ( self::PAY_ONSITE === $payment->mode() && '' !== $payment->address() ? Texts::get( 'waiting_onsite' ) : Texts::get( 'waiting_page' ) ), }; return [ 'id' => $payment->id(), 'status' => $status, 'text' => $text, 'final' => $final, // Nothing will change any more: the browser may drop its memory of the payment. A held, // late or short payment is not closed — the scheduled check can still confirm it // (seen live 2026-10-12: a 401 during confirmation held a payment the next check paid). 'closed' => $closed, // "Start again" only where nothing is pending: a closed payment, or a paid one without // content to keep open. A held, late or AML payment waits for the site owner. 'again' => $closed || ( Payment::STATUS_PAID === $status && ! $unlocked ), 'paid' => Payment::STATUS_PAID === $status, 'payable' => $payable, // Coins are still to come: the address and the amount are shown. Not while the whole // amount is being confirmed, or a payer could send it again. 'send' => $payable && Payment::STATUS_CONFIRMING !== $status, 'mode' => $payment->mode(), 'coin' => '' !== $symbol ? Coins::note_label( $symbol ) : '', 'coin_code' => '' !== $symbol ? Coins::note_coin( $symbol ) : '', 'to_pay' => $to_pay, 'to_send' => $to_send, 'address' => $payment->address(), 'pay_url' => $payment->pay_url(), 'deadline' => $payment->deadline(), 'seconds_left' => $payment->seconds_left(), 'invoiced' => $amounts['invoiced'], 'received' => $amounts['paid'], 'rest' => $amounts['rest'], 'redirect' => Payment::STATUS_PAID === $status && Button::AFTER_REDIRECT === $payment->after() ? $payment->meta( Payment::META_REDIRECT ) : '', 'unlocked' => $unlocked, 'content' => $unlocked ? self::hidden_content( $payment ) : '', ]; } /** * The payment the visitor came back to this button with (?cryptoway-button=done&cwpay=&cwkey=); * p and k would be WordPress's own query vars. */ private static function returned_payment( array $keys, int $post_id ): ?Payment { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the payer's link from the return or the e-mail: the payment's random view key (hash_equals below) is the credential, and it only shows the payment. if ( ! isset( $_GET[ self::QUERY ], $_GET['cwpay'], $_GET['cwkey'] ) || 'done' !== sanitize_key( wp_unslash( $_GET[ self::QUERY ] ) ) ) { return null; } $id = absint( wp_unslash( $_GET['cwpay'] ) ); $view = sanitize_text_field( wp_unslash( $_GET['cwkey'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $payment = Payment::get( $id ); if ( ! $payment || '' === $view || ! hash_equals( $payment->view_key(), $view ) || ! in_array( $payment->button(), $keys, true ) ) { return null; } // The same button may sit on several pages (a widget, a template): only the page it was paid on. if ( $post_id > 0 && $payment->source_post() > 0 && $payment->source_post() !== $post_id ) { return null; } return $payment; } /** The error a form submission without JavaScript came back with. */ private static function error_for( string $key ): string { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a public return link that only selects which plain message to show. if ( ! isset( $_GET[ self::QUERY ], $_GET['e'], $_GET['b'] ) || 'error' !== sanitize_key( wp_unslash( $_GET[ self::QUERY ] ) ) ) { return ''; } if ( sanitize_key( wp_unslash( $_GET['b'] ) ) !== $key ) { return ''; } $code = sanitize_key( wp_unslash( $_GET['e'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended return Checkout::error_text( $code ); } /** Whether the visitor just asked for the access link without JavaScript (?cryptoway-button=sent&b=). */ private static function recover_sent( string $key ): bool { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a public return link that only opens a plain notice. return isset( $_GET[ self::QUERY ], $_GET['b'] ) && 'sent' === sanitize_key( wp_unslash( $_GET[ self::QUERY ] ) ) && sanitize_key( wp_unslash( $_GET['b'] ) ) === $key; // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** Where the form posts to. */ public static function pay_url(): string { return add_query_arg( self::QUERY, 'pay', home_url( '/' ) ); } /** Where "Already paid?" posts to. */ public static function recover_url(): string { return add_query_arg( self::QUERY, 'recover', home_url( '/' ) ); } /** Where the script asks for fresh nonces when a cached page's have expired. */ public static function nonce_url(): string { return add_query_arg( self::QUERY, 'nonce', home_url( '/' ) ); } /** The page a button lives on, for links back to it. */ public static function page_url( int $post_id ): string { $url = $post_id > 0 ? get_permalink( $post_id ) : ''; return is_string( $url ) && '' !== $url ? $url : home_url( '/' ); } // ------------------------------------------------------------------ paid content /** Whether the payer of this payment gets the paid content now. */ public static function is_unlocked( Payment $payment ): bool { return self::AFTER_CONTENT === $payment->after() && self::access_until( $payment ) > time(); } /** * When the payer's access to the paid content ends, as a timestamp; 0 when there is none. * Access starts when the payment is confirmed, or when the site owner opened it by hand (a late * or held payment settled with the payer), and lasts the button's number of days. The owner can * also close it by hand (a refund). */ public static function access_until( Payment $payment ): int { if ( '' !== $payment->meta( Payment::META_ACCESS_REVOKED ) ) { return 0; } $start = max( $payment->is_settled() ? (int) $payment->meta( Payment::META_PAID_AT ) : 0, (int) $payment->meta( Payment::META_ACCESS_GRANTED ) ); if ( $start <= 0 ) { return 0; } $days = max( 1, (int) $payment->meta( Payment::META_ACCESS ) ?: self::ACCESS_DAYS ); return $start + $days * DAY_IN_SECONDS; } /** * The paid content of the button a payment was made with, rendered: the inner blocks of that * block (or the text enclosed by that shortcode). The button is found by its key, so content * never leaks across buttons; '' when it is not found. */ public static function hidden_content( Payment $payment ): string { $paid = self::find_paid( $payment->button(), $payment->source_post() ); if ( null === $paid ) { return ''; } // Dynamic blocks in the paid part read the post they sit in from the global post. $post = $payment->source_post() > 0 ? get_post( $payment->source_post() ) : null; $previous = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post instanceof \WP_Post ? $post : $previous; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored right below. try { return self::render_paid( $paid ); } finally { $GLOBALS['post'] = $previous; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring the previous value. } } /** Whether the button of a paid-content payment can still be found (for the payment screen). */ public static function paid_content_found( Payment $payment ): bool { return null !== self::find_paid( $payment->button(), $payment->source_post() ); } /** * Raw paid content as the page's own HTML: blocks are rendered and embeds expanded the way * the_content() does it, a shortcode's text gets paragraphs and inner shortcodes. * * @param array{kind: string, raw: string} $paid */ private static function render_paid( array $paid ): string { global $wp_embed; $html = $paid['raw']; if ( $wp_embed instanceof \WP_Embed ) { $html = $wp_embed->run_shortcode( $html ); $html = $wp_embed->autoembed( $html ); } if ( 'blocks' === $paid['kind'] ) { $html = do_blocks( $html ); } else { $html = shortcode_unautop( wpautop( $html ) ); } return wp_filter_content_tags( do_shortcode( $html ), 'cryptoway_button' ); } /** * The raw paid content of a button: looked up in the post it sits on (into groups, columns and * synced patterns), then in what was remembered when it was drawn outside post content. * * @return array{kind: string, raw: string, terms: string}|null */ private static function find_paid( string $key, int $post_id ): ?array { if ( '' === $key ) { return null; } $found = $post_id > 0 ? self::locate( $post_id, $key ) : null; if ( null !== $found ) { return $found; } $stored = get_option( self::PAID_PREFIX . md5( $key ) ); if ( is_array( $stored ) && isset( $stored['kind'], $stored['raw'] ) && is_string( $stored['raw'] ) ) { return [ 'kind' => 'blocks' === $stored['kind'] ? 'blocks' : 'text', 'raw' => $stored['raw'], 'terms' => (string) ( $stored['terms'] ?? '' ) ]; } return null; } /** * Keep the raw paid content of a button drawn outside post content (a template part, a block * widget, a shortcode in a widget): hidden_content() finds it there for the payer. A button that * sits in the post (or in a synced pattern in it) is always read from the post, so content * drawn elsewhere never stands in for it. Written only when it changed. * * @param array{kind: string, raw: string} $paid */ private static function remember_paid( string $key, int $post_id, array $paid ): void { // Not from a preview (a draft must not stand in for a published button), the Customizer, the admin or the REST API. if ( ( did_action( 'wp' ) && is_preview() ) || is_customize_preview() || is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } if ( $post_id > 0 && null !== self::locate( $post_id, $key ) ) { return; } $index = get_option( self::PAID_INDEX, [] ); $index = is_array( $index ) ? $index : []; $terms = (string) ( $paid['terms'] ?? '' ); $sum = md5( $paid['kind'] . '|' . $paid['raw'] . '|' . $terms ); if ( ( $index[ $key ] ?? '' ) === $sum ) { return; } update_option( self::PAID_PREFIX . md5( $key ), [ 'kind' => $paid['kind'], 'raw' => $paid['raw'], 'terms' => $terms ], false ); PaidContent::remember_media( $key, $paid ); unset( $index[ $key ] ); $index[ $key ] = $sum; // The newest 200 buttons; an older entry only matters if its button is still drawn, and then it is written again. foreach ( array_slice( array_keys( $index ), 0, max( 0, count( $index ) - 200 ) ) as $old ) { delete_option( self::PAID_PREFIX . md5( (string) $old ) ); PaidContent::remember_media( (string) $old, null ); unset( $index[ $old ] ); } update_option( self::PAID_INDEX, $index, true ); } /** Forget what was found in a post (it was saved again: clean_post_cache). */ public static function forget_post( $post_id ): void { unset( self::$located[ (int) $post_id ] ); } /** * The paid content of the button with this key in a post, if it is there. * * @return array{kind: string, raw: string, terms: string}|null */ private static function locate( int $post_id, string $key ): ?array { if ( ! array_key_exists( $post_id, self::$located ) ) { self::$located[ $post_id ] = self::scan_post( $post_id ); } return self::$located[ $post_id ][ $key ] ?? null; } /** * Every paid-content button of a post, by key: blocks (also nested and in synced patterns) and * shortcodes. * * @return array */ private static function scan_post( int $post_id ): array { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return []; } $found = []; self::scan_blocks( parse_blocks( (string) $post->post_content ), $post_id, $found, [ $post_id => true ], 0 ); self::scan_shortcodes( (string) $post->post_content, $post_id, $found ); return $found; } /** * @param array $blocks * @param array $found Filled in: key => paid content. * @param array $seen Posts already walked (a pattern that includes itself). */ private static function scan_blocks( array $blocks, int $post_id, array &$found, array $seen, int $depth ): void { foreach ( $blocks as $block ) { $name = (string) ( $block['blockName'] ?? '' ); if ( 'cryptoway-button/button' === $name ) { // The attributes as the block is drawn: defaults the editor left out of the markup filled in. $cfg = self::normalise( self::prepared_attributes( is_array( $block['attrs'] ?? null ) ? $block['attrs'] : [] ) ); $cfg['post'] = (string) $post_id; if ( self::AFTER_CONTENT === $cfg['after'] ) { $entry = [ 'kind' => 'blocks', 'raw' => serialize_blocks( (array) ( $block['innerBlocks'] ?? [] ) ), 'terms' => self::terms( $cfg ) ]; foreach ( self::keys( $cfg ) as $key ) { $found[ $key ] ??= $entry; } } continue; } // A synced pattern: its blocks live in a wp_block post of their own. if ( 'core/block' === $name ) { $ref = (int) ( $block['attrs']['ref'] ?? 0 ); if ( $ref > 0 && $depth < 5 && ! isset( $seen[ $ref ] ) ) { $pattern = get_post( $ref ); if ( $pattern instanceof \WP_Post && 'wp_block' === $pattern->post_type && 'publish' === $pattern->post_status ) { self::scan_blocks( parse_blocks( (string) $pattern->post_content ), $post_id, $found, $seen + [ $ref => true ], $depth + 1 ); self::scan_shortcodes( (string) $pattern->post_content, $post_id, $found ); } } continue; } if ( ! empty( $block['innerBlocks'] ) ) { self::scan_blocks( $block['innerBlocks'], $post_id, $found, $seen, $depth ); } } } /** * [cryptoway ...]paid content[/cryptoway] in a text. An escaped [[cryptoway]] is only shown, not run. * * @param array $found Filled in: key => paid content. */ private static function scan_shortcodes( string $text, int $post_id, array &$found ): void { if ( ! str_contains( $text, '[cryptoway' ) ) { return; } $pattern = get_shortcode_regex( [ 'cryptoway', 'cryptoway_button' ] ); if ( ! preg_match_all( '/' . $pattern . '/s', $text, $matches, PREG_SET_ORDER ) ) { return; } foreach ( $matches as $m ) { if ( '[' === $m[1] && ']' === $m[6] ) { continue; } if ( '' === trim( (string) $m[5] ) ) { continue; } $atts = shortcode_parse_atts( $m[3] ); $cfg = self::normalise( self::shortcode_params( is_array( $atts ) ? $atts : [], true ) ); $cfg['post'] = (string) $post_id; foreach ( self::keys( $cfg ) as $key ) { $found[ $key ] ??= [ 'kind' => 'text', 'raw' => (string) $m[5], 'terms' => self::terms( $cfg ) ]; } } } /** Block attributes with the defaults from block.json filled in, as WordPress hands them to Block::render(). */ private static function prepared_attributes( array $attrs ): array { $type = \WP_Block_Type_Registry::get_instance()->get_registered( 'cryptoway-button/button' ); return $type instanceof \WP_Block_Type ? $type->prepare_attributes_for_render( $attrs ) : $attrs; } /** Strings the front-end script shows. */ private static function i18n(): array { return [ 'starting' => __( 'Starting the payment…', 'cryptoway-button' ), 'network' => __( 'Could not reach the site. Please try again.', 'cryptoway-button' ), 'copy' => __( 'Copy', 'cryptoway-button' ), 'copied' => __( 'Copied', 'cryptoway-button' ), 'copyAddress' => __( 'Copy the address', 'cryptoway-button' ), 'copyAmount' => __( 'Copy the amount', 'cryptoway-button' ), 'qr' => __( 'QR code of the address', 'cryptoway-button' ), /* translators: 1: amount in coin, 2: coin with network */ 'send' => __( 'Send exactly %1$s %2$s to this address:', 'cryptoway-button' ), /* translators: %s: minutes and seconds, e.g. 58:40 */ 'timeLeft' => __( 'Time left: %s', 'cryptoway-button' ), 'timeUp' => __( 'The time to pay is up. Checking the payment…', 'cryptoway-button' ), 'again' => __( 'Start again', 'cryptoway-button' ), 'openPage' => __( 'Open the payment page', 'cryptoway-button' ), 'address' => __( 'Address', 'cryptoway-button' ), 'amount' => __( 'Amount', 'cryptoway-button' ), /* translators: %s: smallest amount with currency */ 'minAmount' => __( 'The smallest amount is %s.', 'cryptoway-button' ), 'badAmount' => __( 'Please enter an amount.', 'cryptoway-button' ), 'needEmail' => __( 'Please enter your e-mail: the access link is sent there.', 'cryptoway-button' ), ]; } }