api_key_id = trim( $api_key_id ); $this->api_key_secret = trim( $api_key_secret ); } public static function from_settings(): self { return new self( Settings::get( 'api_key_id' ), Settings::get( 'api_key_secret' ) ); } /** * A copy for reads a visitor's page waits on: 8 seconds and no retries. With Cryptoway down, * a request otherwise holds a worker of the site for about a minute (20 s, twice retried). */ public function quick(): self { $copy = clone $this; $copy->timeout = 8; $copy->retries = 0; return $copy; } /** The API base: the production address, or the CRYPTOWAY_API_BASE constant (test stands). */ private function api_base(): string { $base = defined( 'CRYPTOWAY_API_BASE' ) ? (string) CRYPTOWAY_API_BASE : self::DEFAULT_API_BASE; return rtrim( $base, '/' ); } /** * @return array|\WP_Error */ public function get_profile() { return $this->get( '/user/profile/' ); } /** * One page of the symbol list (coins and networks Cryptoway accepts). * * @return array|\WP_Error */ public function get_symbols( int $page = 1 ) { return $this->get( '/symbol/?per_page=100&page=' . max( 1, $page ) ); } /** * @return array|\WP_Error */ public function create_invoice( array $params ) { $body = [ 'name' => sanitize_text_field( (string) $params['name'] ), 'target_value_fiat' => $params['target_value_fiat'], 'target_currency_fiat' => strtoupper( (string) $params['target_currency_fiat'] ), 'external_id' => (string) $params['external_id'], 'callback_url' => $params['callback_url'], ]; if ( ! empty( $params['deadline_at'] ) ) { $body['deadline_at'] = $params['deadline_at']; } if ( ! empty( $params['description'] ) ) { $body['description'] = sanitize_text_field( (string) $params['description'] ); } // With symbol_id Cryptoway fixes the coin and returns the wallet address at once // (target_wallet.value): the on-site payment panel needs that. if ( ! empty( $params['symbol_id'] ) ) { $body['symbol_id'] = sanitize_text_field( (string) $params['symbol_id'] ); } // redirect_url returns the payer to the site; if it is refused, the request is retried without it. $redirect_url = (string) ( $params['redirect_url'] ?? '' ); if ( '' !== $redirect_url ) { $body['redirect_url'] = $redirect_url; } $result = $this->post( '/invoice/', $body ); if ( ! is_wp_error( $result ) || ! isset( $body['redirect_url'] ) ) { return $result; } // 400/422 usually mean an unknown field. 500 is included too: an unknown // field may make the server fail instead of refusing it. $status = (int) ( $result->get_error_data()['status'] ?? 0 ); if ( ! in_array( $status, [ 400, 422, 500 ], true ) ) { return $result; } unset( $body['redirect_url'] ); Log::write( sprintf( 'Invoice rejected (HTTP %d) with redirect_url — retrying without that field.', $status ) ); return $this->post( '/invoice/', $body ); } /** * @return array|\WP_Error */ public function get_invoice( string $invoice_id ) { $invoice_id = trim( $invoice_id ); if ( ! preg_match( '/^[A-Za-z0-9._-]{6,64}$/', $invoice_id ) ) { return new \WP_Error( 'cryptoway_button_bad_invoice_id', sprintf( /* translators: %s - invoice identifier */ __( 'Unrecognised invoice number: %s', 'cryptoway-button' ), $invoice_id ) ); } return $this->get( '/invoice/my/' . rawurlencode( $invoice_id ) . '/' ); } /** * @return array|\WP_Error */ private function get( string $path ) { return $this->unwrap( $this->request( 'GET', $path ) ); } /** * @return array|\WP_Error */ private function post( string $path, array $body ) { return $this->unwrap( $this->request( 'POST', $path, $body ) ); } /** * Retries after a network error or a 5xx, for GET only: repeating a POST could create * a second invoice, and repeating a 4xx gets the same answer. */ private const RETRIES = 2; /** Pause before a retry, in microseconds. Doubles on the second attempt. */ private const RETRY_PAUSE_US = 600000; /** Send a signed request and return a summary: status, raw, decoded, skew, error, url. */ private function request( string $method, string $path, array $body = [] ): array { $parts = explode( '?', $path, 2 ); $clean_path = $parts[0]; $params = $parts[1] ?? ''; $timestamp = time(); $envelope = Signature::api_envelope( $clean_path, $params, 'POST' === $method ? $body : [] ); $signature = Signature::sign( $timestamp, $envelope, $this->api_key_secret ); $url = $this->api_base() . $path; $args = [ 'method' => $method, 'timeout' => $this->timeout, 'user-agent' => 'CryptowayButton/' . CRYPTOWAY_BUTTON_VERSION . '; ' . home_url( '/' ), 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-API-KEY-ID' => $this->api_key_id, 'X-TIMESTAMP' => (string) $timestamp, 'X-SIGNATURE' => $signature, ], ]; if ( 'POST' === $method ) { // The same canonical string is signed and sent. $args['body'] = Signature::canonical( Signature::to_object( $body ) ); } $response = wp_remote_request( $url, $args ); $received_at = time(); $repeatable = 'GET' === $method; $attempt = 0; while ( $repeatable && $attempt < $this->retries && ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500 ) ) { ++$attempt; usleep( self::RETRY_PAUSE_US * $attempt ); $retry_timestamp = time(); $args['headers']['X-TIMESTAMP'] = (string) $retry_timestamp; $args['headers']['X-SIGNATURE'] = Signature::sign( $retry_timestamp, $envelope, $this->api_key_secret ); $response = wp_remote_request( $url, $args ); $received_at = time(); $timestamp = $retry_timestamp; } $out = [ 'status' => 0, 'raw' => '', 'decoded' => null, 'skew' => null, 'error' => null, 'url' => $url, ]; if ( is_wp_error( $response ) ) { $out['error'] = new \WP_Error( 'cryptoway_button_http_error', sprintf( /* translators: 1: request URL, 2: error message */ __( 'Could not reach Cryptoway at %1$s: %2$s', 'cryptoway-button' ), $url, $response->get_error_message() ), [ 'url' => $url ] ); Log::write( sprintf( 'NETWORK ERROR %s %s: %s', $method, $url, $response->get_error_message() ) ); return $out; } $out['status'] = (int) wp_remote_retrieve_response_code( $response ); $out['raw'] = (string) wp_remote_retrieve_body( $response ); $out['decoded'] = json_decode( $out['raw'], true ); $out['skew'] = $this->measure_clock_skew( $response, $timestamp, $received_at ); if ( $out['status'] < 200 || $out['status'] >= 300 ) { $shown = self::mask_secrets( $out['raw'] ); $shown = strlen( $shown ) > 300 ? substr( $shown, 0, 300 ) . '…' : $shown; Log::write( sprintf( 'API ERROR: %s %s -> HTTP %d: %s%s', $method, $url, $out['status'], '' === $shown ? '(empty)' : $shown, null !== $out['skew'] ? sprintf( ' (clock skew %+d s)', $out['skew'] ) : '' ) ); } return $out; } /** * Request summary -> data array or WP_Error. * * @return array|\WP_Error */ private function unwrap( array $response ) { if ( $response['error'] instanceof \WP_Error ) { return $response['error']; } $status = (int) $response['status']; if ( $status >= 200 && $status < 300 ) { // A signed request that went through shows the address is allowed; the public coin list does not. if ( is_array( $response['decoded'] ) && ! str_contains( (string) $response['url'], '/symbol/' ) ) { self::note_ip_refused( false, str_contains( (string) $response['url'], '/user/profile/' ) ); } return is_array( $response['decoded'] ) ? $response['decoded'] : []; } if ( self::is_ip_refusal( $status, (string) $response['raw'] ) ) { self::note_ip_refused( true ); } $parts = $this->explain_parts( $status, (string) $response['raw'], $response['skew'] ); return new \WP_Error( 'cryptoway_button_api_error', implode( ' — ', array_filter( $parts ) ), [ 'status' => $status, 'url' => $response['url'], 'body' => $response['raw'], // The plain parts on their own, for an admin who tries the button. 'hint' => $parts['hint'], 'skew' => $parts['skew'], ] ); } /** * A project's keys work only from the addresses in its Allowed IPs (an empty list takes the first * one used); a request from another address gets a 401 like a wrong key ("Invalid IP for this API key!"). */ private static function is_ip_refusal( int $status, string $raw ): bool { return ( 401 === $status || 403 === $status ) && 1 === preg_match( '/\bip\b/i', self::detail_of( $raw ) ); } private static function detail_of( string $raw ): string { $decoded = json_decode( $raw, true ); $detail = is_array( $decoded ) ? ( $decoded['detail'] ?? '' ) : ''; return is_array( $detail ) ? (string) wp_json_encode( $detail, JSON_UNESCAPED_UNICODE ) : (string) $detail; } /** * Remember when Cryptoway last refused the keys from this server's address (written at most once a * minute), or forget it: after ten quiet minutes, or at once when the keys were just checked (the * settings were saved). A server with several outgoing addresses keeps the notice while any is refused. */ private static function note_ip_refused( bool $refused, bool $checked = false ): void { $last = (int) get_option( self::IP_REFUSED, 0 ); if ( $refused ) { if ( time() - $last > MINUTE_IN_SECONDS ) { update_option( self::IP_REFUSED, time(), false ); } } elseif ( $last > 0 && ( $checked || time() - $last > 10 * MINUTE_IN_SECONDS ) ) { delete_option( self::IP_REFUSED ); } } /** * The error message of a non-2xx response, in parts: a plain hint first, the HTTP code with the API * detail, and the clock sentence. * * @return array{hint: string, http: string, skew: string} */ private function explain_parts( int $status, string $raw, ?int $skew ): array { $detail = self::detail_of( $raw ); $wrong_ip = self::is_ip_refusal( $status, $raw ); $hint = match ( true ) { $wrong_ip => __( 'Cryptoway accepts these keys only from the addresses in the project\'s Allowed IPs, and this server\'s address is not there. In your Cryptoway account add the address this server uses to go online, or its range, to Allowed IPs (your hosting provider can tell you the address).', 'cryptoway-button' ), 401 === $status, 403 === $status => __( 'Cryptoway did not accept the keys. Check the Project ID and the Secret key, and make sure the project is active.', 'cryptoway-button' ), 404 === $status => __( 'Cryptoway answered "not found". Usually the project is not allowed to accept payments yet — check the project in your Cryptoway account.', 'cryptoway-button' ), 422 === $status => __( 'Cryptoway did not accept the payment details. Check the currency, the amount and the coin.', 'cryptoway-button' ), 429 === $status => __( 'Too many requests to Cryptoway at once. Please try again in a minute.', 'cryptoway-button' ), $status >= 500 => __( 'Something went wrong on the Cryptoway side. Please try again later.', 'cryptoway-button' ), default => '', }; $clock = ''; if ( null !== $skew && abs( $skew ) > self::CLOCK_TOLERANCE ) { $clock = sprintf( /* translators: 1: skew in seconds, 2: allowed tolerance */ __( 'The server clock is %1$+d s off from Cryptoway, and only ±%2$d s is allowed. That alone is enough to block payments — correct the server time.', 'cryptoway-button' ), $skew, self::CLOCK_TOLERANCE ); } return [ 'hint' => $hint, 'http' => sprintf( 'HTTP %d', $status ) . ( '' !== $detail ? ': ' . $detail : '' ), 'skew' => $clock, ]; } /** Clock skew from the response Date header. */ private function measure_clock_skew( $response, int $sent_at, ?int $received_at = null ): ?int { $date = wp_remote_retrieve_header( $response, 'date' ); if ( is_array( $date ) ) { $date = reset( $date ); } if ( ! is_string( $date ) || '' === $date ) { return null; } $server_time = strtotime( $date ); if ( false === $server_time ) { return null; } // The Date header is stamped between sending and receiving: compare with the // midpoint, so a slow response does not look like clock skew. $received_at = null === $received_at ? $sent_at : max( $sent_at, $received_at ); $midpoint = intdiv( $sent_at + $received_at, 2 ); $skew = $server_time - $midpoint; // A request slower than the tolerance gives no reliable measurement. if ( ( $received_at - $sent_at ) > self::CLOCK_TOLERANCE ) { return null; } set_transient( self::CLOCK_SKEW_TRANSIENT, $skew, DAY_IN_SECONDS ); return $skew; } /** * Text with secrets masked: the callback key (the profile response carries it) and the * payment keys in URLs. The log is what a site owner sends to support. */ public static function mask_secrets( string $raw ): string { $raw = (string) preg_replace( '/("(?:callback_key|callback_key_secret|callback_secret|secret|api_key_secret)"\s*:\s*")[^"]*(")/', '$1***$2', $raw ); return (string) preg_replace( '/([?&](?:cwkey|cwret)=)[^&"\s#\\\\]+/', '$1***', $raw ); } }