network = (string) get_option( Prweb_Brand::option( 'network' ), 'testnet' ); $api_base = (string) get_option( Prweb_Brand::option( 'api_base' ), '' ); $this->api_base = $api_base !== '' ? $api_base : self::default_api_base(); $this->user_id = class_exists( 'Prweb_Plugin' ) ? (string) ( Prweb_Plugin::current_pds_user_id() ?? '' ) : (string) get_option( Prweb_Brand::option( 'user_id' ), '' ); // Legacy denorm cache only read when the emergency fallback is enabled. $this->prv_key = ( class_exists( 'Prweb_Plugin' ) && Prweb_Plugin::is_legacy_auth_enabled() ) ? (string) get_option( Prweb_Brand::option( 'prv_key' ), '' ) : ''; $this->auth_proxy = $auth_proxy; } public static function default_api_base(): string { $value = Prweb_Brand::config( 'PDS_API_BASE' ); if ( ! is_string( $value ) || trim( $value ) === '' ) { $value = getenv( 'PDS_API_BASE' ); } return is_string( $value ) ? rtrim( trim( $value ), '/' ) : ''; } private function funder_id(): string { $value = Prweb_Brand::config( 'FUNDER_ID' ); return is_scalar( $value ) ? trim( (string) $value ) : ''; } private function funder_key(): string { $value = Prweb_Brand::config( 'FUNDER_KEY' ); return is_scalar( $value ) ? trim( (string) $value ) : ''; } private function fund_leos(): int { $raw = Prweb_Brand::config( 'PDS_FUND_LEOS' ); if ( is_numeric( $raw ) && (int) $raw > 0 ) { return (int) $raw; } return self::DEFAULT_FUND_LEOS; } /** * Credit-line TTL in seconds for POST /fundings/create (ORL-44). Sourced from * PRWEB_PDS_FUND_TTL; defaults to one year (31536000) when unset/invalid. * Mirrors the numeric-validation pattern of fund_leos(). */ private function fund_ttl(): int { $raw = Prweb_Brand::config( 'PDS_FUND_TTL' ); if ( is_numeric( $raw ) && (int) $raw > 0 ) { return (int) $raw; } return self::DEFAULT_FUND_TTL; } /** * Per-transaction Żetons draw cap for the credit line (ORL-44). Sourced from * PRWEB_PDS_FUND_LEOS_PER_TX; defaults to the total credit (fund_leos()) when * unset/invalid, so a single draw can consume the whole line by default. * Mirrors the numeric-validation pattern of fund_leos(). */ private function fund_leos_per_tx(): int { $raw = Prweb_Brand::config( 'PDS_FUND_LEOS_PER_TX' ); if ( is_numeric( $raw ) && (int) $raw > 0 ) { return (int) $raw; } return $this->fund_leos(); } private function require_funder(): ?WP_Error { if ( $this->funder_id() === '' || $this->funder_key() === '' ) { return new WP_Error( 'prweb_funder_unset', __( 'OpenRSL funder is not configured. Define PRWEB_FUNDER_ID and PRWEB_FUNDER_KEY in wp-config.php or the runtime environment.', 'propertyrightsweb' ) ); } return null; } /** Fail before any reader-side mutation when required runtime config is absent. */ public function reader_registration_preflight(): ?WP_Error { if ( '' === $this->api_base ) { return new WP_Error( 'prweb_pds_api_unset', __( 'OpenRSL PDS API is not configured.', 'propertyrightsweb' ) ); } return $this->require_funder(); } /** * Returns the demo-funder Żetons balance. Distinguishes the configuration * error (funder unset → WP_Error) from a transient probe failure (returns * 0) so the caller can render a setup notice rather than a confusing * "Funder balance: 0 Żetons" when the operator simply hasn't set the * PRWEB_FUNDER_* constants yet. */ public function get_funder_balance(): int|WP_Error { $err = $this->require_funder(); if ( $err !== null ) { return $err; } return $this->get_user_balance( $this->funder_id() ); } public function get_user_balance( string $user_id ): int { $response = $this->read( '/balances', [ 'zetoniumUserId' => $user_id ] ); if ( is_wp_error( $response ) ) { // Transient — caller falls back to 0; UI degrades rather than refusing traffic. Prweb_Log::warn( 'pds balance check failed', [ 'category' => 'pds_client', 'error_msg' => $response->get_error_message(), ] ); return 0; } $data = $this->result_of( $response ); foreach ( [ 'silver_leos', 'silverLeos', 'balance' ] as $field ) { if ( is_object( $data ) && isset( $data->$field ) ) { return (int) $data->$field; } } return 0; } public function register_and_fund(): array|WP_Error { $err = $this->require_funder(); if ( $err !== null ) { return $err; } $user_resp = $this->api_call( '/users/create', [] ); if ( is_wp_error( $user_resp ) ) { return $user_resp; } $data = $this->result_of( $user_resp ); $new_id = $data->userId ?? $data->user_id ?? $data->id ?? null; $new_key = $data->prvKey ?? $data->privateKey ?? $data->prv_key ?? null; if ( ! $new_id ) { return new WP_Error( 'pds_error', 'PDS did not return a user id: ' . wp_json_encode( $user_resp ) ); } // ORL-44 (Option A): credit-fund the new publisher via POST /fundings/create // instead of a direct /balances/transfer. The funder opens a credit LINE // (a drawn-against allowance with a TTL) rather than moving a flat balance. // Body field names differ from /balances/transfer: funderId / userToFundId / // funderCredentials (not sender/receiver) with nested `terms` (not flat // currencyAmount). Mirrors scripts/provision-vm-credentials.sh. // Idempotency-Key makes the funding leg recovery-safe: a retry after a // partial /users/create + funding failure dedups to a single credit line. $funding = $this->api_call( '/fundings/create', [ 'funderId' => $this->funder_id(), 'userToFundId' => $new_id, 'funderCredentials' => $this->funder_key(), 'termSheetTemplate' => 'fundingTerms', 'terms' => [ 'totalLeos' => $this->fund_leos(), 'leosPerTx' => $this->fund_leos_per_tx(), 'ttl' => $this->fund_ttl(), ], ], [], [ 'Idempotency-Key' => 'webapp-fund-' . $new_id ] ); if ( is_wp_error( $funding ) ) { return $funding; } return [ 'userId' => $new_id, 'prvKey' => $new_key, 'tx' => $this->result_of( $funding ), ]; } public function add_dab( int $post_id, string $asset_url, string $description, string $content_hash ): mixed { // PDS enforces global uniqueness on assetUrl — namespace by seller so the same permalink can re-register after Clear. $owner_id = $this->auth_proxy !== null ? (string) ( Prweb_Token_Store::get_pds_user_id() ?? $this->user_id ) : $this->user_id; $namespaced_url = $asset_url . ( str_contains( $asset_url, '#' ) ? '&' : '#' ) . 'pdsSeller=' . substr( $owner_id, 2, 10 ); $body = [ 'ownerId' => $owner_id, 'dataArr' => [ [ 'description' => $description, 'assetUrl' => $namespaced_url, 'dataHash' => $this->hex_hash( $content_hash ), ], ], ]; if ( $this->auth_proxy !== null ) { $proxied = $this->auth_proxy->request( 'owner', '/assets/create', $body ); $err = Prweb_Pds_Proxy_Adapter::wp_error( $proxied, 'DAB creation' ); if ( $err !== null ) { return $err; } $response = Prweb_Pds_Proxy_Adapter::envelope( $proxied ); } else { $body['ownerCredentials'] = $this->prv_key; $response = $this->api_call( '/assets/create', $body ); } if ( is_wp_error( $response ) ) { return $response; } $data = $this->result_of( $response ); // v37+ returns [""]; older versions returned a scalar. if ( is_array( $data ) && ! empty( $data ) ) { return (int) $data[0]; } if ( is_numeric( $data ) ) { return (int) $data; } // 1411 = duplicate assetUrl: recover the existing DAB id so registration is idempotent. $err_code = is_object( $response ) ? ( $response->error->code ?? 0 ) : 0; if ( 1411 === (int) $err_code ) { $existing = $this->lookups()->find_dab_by_description( $description ); if ( $existing !== null ) { return $existing; } } return new WP_Error( 'pds_error', 'DAB creation returned no usable result: ' . wp_json_encode( $response ) ); } public function create_offer( int $dab_id, int $price_leos, array $terms ): mixed { $seller_id = $this->auth_proxy !== null ? (string) ( Prweb_Token_Store::get_pds_user_id() ?? $this->user_id ) : $this->user_id; $body = [ 'tradeType' => 'SELL', 'licenseType' => 'ACCESS', 'sellerId' => $seller_id, 'sellerDabId' => $dab_id, 'minLeosPrice' => $price_leos, 'market' => true, 'licenseTermSheetTemplate' => 'licenseAccessTerms', 'licenseTerms' => $terms, ]; if ( $this->auth_proxy !== null ) { $proxied = $this->auth_proxy->request( 'seller', '/offers/create', $body ); $err = Prweb_Pds_Proxy_Adapter::wp_error( $proxied, 'Offer creation' ); if ( $err !== null ) { return $err; } $response = Prweb_Pds_Proxy_Adapter::envelope( $proxied ); } else { $body['sellerCredentials'] = $this->prv_key; $response = $this->api_call( '/offers/create', $body ); } if ( is_wp_error( $response ) ) { return $response; } // PDS responses: numeric/array offerId, deal-incomplete envelope, or // error-looking {name,id}. Try the index fallback before treating // {name,id} as rejection so successful non-deal envelopes do not break // registration. $data = $this->result_of( $response ); $direct = $this->extract_offer_id( $data ); if ( $direct !== null ) { return $direct; } $latest = $this->lookups()->find_open_sell_offer( $dab_id ); if ( $latest !== null ) { return $latest; } if ( $this->is_pds_error_envelope( $response ) ) { return new WP_Error( 'pds_error', sprintf( /* translators: 1: PDS-side error name, 2: numeric error code. */ __( 'PDS rejected offer: %1$s (code %2$d).', 'propertyrightsweb' ), (string) $response->name, (int) $response->id ) ); } return new WP_Error( 'pds_error', 'Offer creation: no offer id resolvable: ' . wp_json_encode( $response ) ); } /** Detect `{name,id}` error envelope; excludes the consummation envelope which shares the shape. */ private function is_pds_error_envelope( mixed $response ): bool { if ( ! is_object( $response ) ) { return false; } $name = $response->name ?? null; $id = $response->id ?? null; if ( ! is_string( $name ) || ! is_numeric( $id ) ) { return false; } return ! preg_match( '/deal|consum/i', $name ); } private function extract_offer_id( mixed $data ): ?int { if ( is_numeric( $data ) ) { return (int) $data; } if ( is_array( $data ) && ! empty( $data ) && is_numeric( $data[0] ) ) { return (int) $data[0]; } return null; } /** Open a BUY ACCESS offer against a known SELL. Response may be a numeric offerId, a consummation envelope, or a license row to chase via /licenses. */ public function open_buy_offer( string $buyer_id, string $buyer_credentials, string $reseller_id, int $dab_id, int $max_price = 1000, string $idempotency_key = '' ): array|WP_Error { $response = $this->api_call( '/offers/create', [ 'tradeType' => 'BUY', 'licenseType' => 'ACCESS', 'buyerId' => $buyer_id, 'buyerCredentials' => $buyer_credentials, 'resellerId' => $reseller_id, 'dabId' => $dab_id, 'maxLeosPrice' => $max_price, 'termSheetTemplate' => 'licenseAccessTerms', 'terms' => [ 'intent' => 'read' ], ], [], $this->idempotency_headers( $idempotency_key, 'buy' ) ); if ( is_wp_error( $response ) ) { return $response; } $data = $this->result_of( $response ); $direct = $this->extract_offer_id( $data ); if ( $direct !== null ) { return [ 'offer_id' => (string) $direct, 'txid' => (string) $direct ]; } if ( $this->is_consummation_envelope( $response ) ) { $license = ( new Prweb_Pds_Lookups( $this, $buyer_id ) )->find_latest_buy_license( $buyer_id, $dab_id ); if ( $license !== null ) { return [ 'offer_id' => (string) ( $license->buyOfferId ?? '' ), 'txid' => (string) ( $license->tagId ?? $license->buyOfferId ?? '' ), ]; } return [ 'offer_id' => 'consummated', 'txid' => 'consummated' ]; } return new WP_Error( 'pds_error', 'BUY offer: missing offer id in response: ' . wp_json_encode( $response ) ); } private function is_consummation_envelope( mixed $response ): bool { if ( ! is_object( $response ) ) { return false; } $name = $response->name ?? null; return is_string( $name ) && (bool) preg_match( '/deal|consum/i', $name ); } /** * Transfer Żetons from the demo Funder to an arbitrary recipient (e.g. fresh * reader before first BUY). * * ORL-44 decision — Option B (deliberate, NOT an oversight): this path KEEPS * the direct POST /balances/transfer while register_and_fund() migrates to * credit funding (Option A, POST /fundings/create). The choice is explicit: * fund_user is an ad-hoc one-off reader top-up (5000 Żetons, "fresh reader * before first BUY") that needs spendable Żetons *now*. A credit LINE — a * drawn-against allowance with a TTL — is the wrong semantics for an * immediate single-BUY top-up; the reader must be able to spend the balance * on the very next call, not draw it down over time. So the credit-funding * migration intentionally applies only to publisher provisioning. */ public function fund_user( string $recipient_id, int $leos = 5000, string $idempotency_key = '' ): mixed { $err = $this->require_funder(); if ( $err !== null ) { return $err; } $funding = $this->api_call( '/balances/transfer', [ 'senderCurrency' => 'SILVER', 'receiverCurrency' => 'SILVER', 'senderId' => $this->funder_id(), 'senderCredentials' => $this->funder_key(), 'receiverId' => $recipient_id, 'currencyAmount' => $leos, ], [], $this->idempotency_headers( $idempotency_key, 'fund' ) ); if ( is_wp_error( $funding ) ) { return $funding; } return $this->result_of( $funding ); } /** Create a fresh PDS user with no funding. Caller tops up via fund_user() if needed. */ public function create_user( string $idempotency_key = '' ): array|WP_Error { $resp = $this->api_call( '/users/create', [], [], $this->idempotency_headers( $idempotency_key, 'identity' ) ); if ( is_wp_error( $resp ) ) { return $resp; } $data = $this->result_of( $resp ); $user_id = $data->userId ?? $data->user_id ?? $data->id ?? null; $prv_key = $data->prvKey ?? $data->privateKey ?? $data->prv_key ?? null; if ( ! $user_id || ! $prv_key ) { return new WP_Error( 'pds_error', 'PDS /users/create returned no credentials: ' . wp_json_encode( $resp ) ); } return [ 'user_id' => (string) $user_id, 'prv_key' => (string) $prv_key ]; } /** @return array */ private function idempotency_headers( string $key, string $step ): array { if ( '' === $key ) { return []; } return [ 'Idempotency-Key' => $key . ':' . $step ]; } public function revoke_offer( int $dab_id ): mixed { $user_id = $this->auth_proxy !== null ? (string) ( Prweb_Token_Store::get_pds_user_id() ?? $this->user_id ) : $this->user_id; $body = [ 'market' => true, 'userId' => $user_id, 'licenseType' => 'ACCESS', 'sellerDabId' => $dab_id, ]; if ( $this->auth_proxy !== null ) { $proxied = $this->auth_proxy->request( 'user', '/offers/cancel', $body ); $err = Prweb_Pds_Proxy_Adapter::wp_error( $proxied, 'Offer revoke' ); if ( $err !== null ) { return $err; } $response = Prweb_Pds_Proxy_Adapter::envelope( $proxied ); } else { $body['userCredentials'] = $this->prv_key; $response = $this->api_call( '/offers/cancel', $body ); } if ( is_wp_error( $response ) ) { return $response; } $settled = $this->lookups()->wait_for_revoke_to_settle( $dab_id ); if ( is_wp_error( $settled ) ) { return $settled; } return $this->result_of( $response ); } private function lookups(): Prweb_Pds_Lookups { return new Prweb_Pds_Lookups( $this, $this->user_id ); } /** Credential-free PDS read; routes through the auth proxy when present, else legacy direct call. */ public function read( string $path, array $query ): mixed { if ( $this->auth_proxy === null ) { return $this->api_call( $path, null, $query ); } $proxied = $this->auth_proxy->request_get( $path, $query ); $err = Prweb_Pds_Proxy_Adapter::wp_error( $proxied, 'PDS read ' . $path ); if ( $err !== null ) { return $err; } return Prweb_Pds_Proxy_Adapter::envelope( $proxied ); } private function hex_hash( string $hash ): string { return str_starts_with( $hash, '0x' ) ? $hash : '0x' . $hash; } public function result_of( mixed $response ): mixed { if ( is_object( $response ) && isset( $response->result ) ) { return $response->result; } return $response; } /** * Sole HTTP helper for the PDS REST API. * * $extra_headers (default []) is merged into the base headers so callers can * attach per-call headers — notably an Idempotency-Key for recovery-safe * mutations (e.g. credit funding). Backward-compatible: existing callers * pass nothing and the behavior is unchanged. */ private function api_call( string $path, ?array $body = null, array $query = [], array $extra_headers = [] ): mixed { $endpoint = rtrim( $this->api_base, '/' ) . $path; if ( ! empty( $query ) ) { $endpoint = add_query_arg( $query, $endpoint ); } $request_id = isset( $_SERVER['HTTP_X_REQUEST_ID'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_X_REQUEST_ID'] ) ) : ''; $headers = [ 'Content-Type' => 'application/json' ]; if ( '' !== $request_id ) { $headers['X-Request-ID'] = $request_id; } $args = [ 'timeout' => 20, 'sslverify' => 'production' === $this->network, 'headers' => array_merge( $headers, $extra_headers ), ]; if ( is_array( $body ) ) { $args['method'] = 'POST'; if ( ! empty( $body ) ) { $args['body'] = wp_json_encode( $body ); } } else { $args['method'] = 'GET'; } $response = wp_remote_request( $endpoint, $args ); if ( is_wp_error( $response ) ) { Prweb_Pds_Debug_Log::record( $endpoint, $args, [ 'error' => $response->get_error_message() ] ); return $response; } $raw = wp_remote_retrieve_body( $response ); $decoded = json_decode( $raw ); Prweb_Pds_Debug_Log::record( $endpoint, $args, $decoded ?? $raw ); return $decoded; } }