From 54aaba6f455ea743f0e9a927ed3441076c695af7 Mon Sep 17 00:00:00 2001 From: Lucas Lois Date: Wed, 13 May 2026 23:11:26 +0200 Subject: [PATCH] feat(wallets): request_expiry kwarg on update/rpc/raw_sign/transfer Committed-By-Agent: claude --- lib/privy/public_api/services/wallets.rb | 29 +++- .../privy/public_api/services/wallets_test.rb | 131 +++++++++++++++++- 2 files changed, 152 insertions(+), 8 deletions(-) diff --git a/lib/privy/public_api/services/wallets.rb b/lib/privy/public_api/services/wallets.rb index 39cef70..3234d50 100644 --- a/lib/privy/public_api/services/wallets.rb +++ b/lib/privy/public_api/services/wallets.rb @@ -54,16 +54,20 @@ def create(wallet_create_params:, idempotency_key: nil, request_options: nil) # @option wallet_update_params [String, nil] :display_name A human-readable label for the wallet. Set to nil to clear. # @option wallet_update_params [Array, nil] :additional_signers Additional signers for the wallet. # @param authorization_context [Privy::Authorization::AuthorizationContext, nil] Authorization context for owned wallets. + # @param request_expiry [Integer, nil] Absolute Unix-ms timestamp at which the + # request expires. Defaults to the value computed by the client's + # PrivyRequestExpiryOptions. # @param request_options [Privy::RequestOptions, Hash, nil] Transport-level config (timeouts, retries). # # @return [Privy::Models::Wallet] - def update(wallet_id, wallet_update_params:, authorization_context: nil, request_options: nil) + def update(wallet_id, wallet_update_params:, authorization_context: nil, request_expiry: nil, request_options: nil) prepared = Privy::Authorization.prepare_request( privy_client, method: :patch, url: Privy::Authorization.signed_url(privy_client, "v1/wallets/#{wallet_id}"), body: wallet_update_params, - authorization_context: authorization_context + authorization_context: authorization_context, + request_expiry: privy_client.compute_request_expiry(request_expiry) ) combined_params = wallet_update_params.merge(request_options: request_options) Privy::Authorization.merge_prepared_headers!(combined_params, prepared.headers) @@ -93,6 +97,9 @@ def update(wallet_id, wallet_update_params:, authorization_context: nil, request # @option wallet_rpc_request_body [Hash] :params Method-specific parameters. # @param authorization_context [Privy::Authorization::AuthorizationContext, nil] Authorization context for owned wallets. # @param idempotency_key [String, nil] Ensures the request is executed only once. + # @param request_expiry [Integer, nil] Absolute Unix-ms timestamp at which the + # request expires. Defaults to the value computed by the client's + # PrivyRequestExpiryOptions. # @param request_options [Privy::RequestOptions, Hash, nil] Transport-level config (timeouts, retries). # # @return [Privy::Models::WalletRpcResponse] @@ -101,6 +108,7 @@ def rpc( wallet_rpc_request_body:, authorization_context: nil, idempotency_key: nil, + request_expiry: nil, request_options: nil ) prepared = Privy::Authorization.prepare_request( @@ -109,7 +117,8 @@ def rpc( url: Privy::Authorization.signed_url(privy_client, "v1/wallets/#{wallet_id}/rpc"), body: wallet_rpc_request_body, authorization_context: authorization_context, - idempotency_key: idempotency_key + idempotency_key: idempotency_key, + request_expiry: privy_client.compute_request_expiry(request_expiry) ) combined_params = {wallet_rpc_request_body: wallet_rpc_request_body, request_options: request_options} Privy::Authorization.merge_prepared_headers!(combined_params, prepared.headers) @@ -133,6 +142,9 @@ def rpc( # @option raw_sign_input [Hash] :params The signing parameters (required). Either {hash:} or {bytes:, encoding:, hash_function:}. # @param authorization_context [Privy::Authorization::AuthorizationContext, nil] Authorization context for owned wallets. # @param idempotency_key [String, nil] Ensures the request is executed only once. + # @param request_expiry [Integer, nil] Absolute Unix-ms timestamp at which the + # request expires. Defaults to the value computed by the client's + # PrivyRequestExpiryOptions. # @param request_options [Privy::RequestOptions, Hash, nil] Transport-level config (timeouts, retries). # # @return [Privy::Models::RawSignResponse] @@ -141,6 +153,7 @@ def raw_sign( raw_sign_input:, authorization_context: nil, idempotency_key: nil, + request_expiry: nil, request_options: nil ) prepared = Privy::Authorization.prepare_request( @@ -149,7 +162,8 @@ def raw_sign( url: Privy::Authorization.signed_url(privy_client, "v1/wallets/#{wallet_id}/raw_sign"), body: raw_sign_input, authorization_context: authorization_context, - idempotency_key: idempotency_key + idempotency_key: idempotency_key, + request_expiry: privy_client.compute_request_expiry(request_expiry) ) combined_params = raw_sign_input.merge(request_options: request_options) Privy::Authorization.merge_prepared_headers!(combined_params, prepared.headers) @@ -180,6 +194,9 @@ def raw_sign( # @option wallet_transfer_params [Integer] :slippage_bps Maximum allowed slippage in basis points (1 bps = 0.01%). # @param authorization_context [Privy::Authorization::AuthorizationContext, nil] Authorization context for owned wallets. # @param idempotency_key [String, nil] Ensures the request is executed only once. + # @param request_expiry [Integer, nil] Absolute Unix-ms timestamp at which the + # request expires. Defaults to the value computed by the client's + # PrivyRequestExpiryOptions. # @param request_options [Privy::RequestOptions, Hash, nil] Transport-level config (timeouts, retries). # # @return [Privy::Models::TransferActionResponse] @@ -188,6 +205,7 @@ def transfer( wallet_transfer_params:, authorization_context: nil, idempotency_key: nil, + request_expiry: nil, request_options: nil ) prepared = Privy::Authorization.prepare_request( @@ -196,7 +214,8 @@ def transfer( url: Privy::Authorization.signed_url(privy_client, "v1/wallets/#{wallet_id}/transfer"), body: wallet_transfer_params, authorization_context: authorization_context, - idempotency_key: idempotency_key + idempotency_key: idempotency_key, + request_expiry: privy_client.compute_request_expiry(request_expiry) ) combined_params = wallet_transfer_params.merge(request_options: request_options) Privy::Authorization.merge_prepared_headers!(combined_params, prepared.headers) diff --git a/test/privy/public_api/services/wallets_test.rb b/test/privy/public_api/services/wallets_test.rb index f9edddb..467050e 100644 --- a/test/privy/public_api/services/wallets_test.rb +++ b/test/privy/public_api/services/wallets_test.rb @@ -87,8 +87,10 @@ def test_update_with_auth_context_signs_canonical_payload ) captured_sig = nil + captured_expiry = nil assert_requested(:patch, "#{BASE_URL}/v1/wallets/w-1") do |req| captured_sig = req.headers["Privy-Authorization-Signature"] + captured_expiry = req.headers["Privy-Request-Expiry"] end expected_payload = Privy::Authorization.format_request_for_authorization_signature( @@ -96,7 +98,10 @@ def test_update_with_auth_context_signs_canonical_payload method: :patch, url: "#{BASE_URL}/v1/wallets/w-1", body: {display_name: "new"}, - headers: {"privy-app-id" => "app-abc"} + headers: { + "privy-app-id" => "app-abc", + "privy-request-expiry" => captured_expiry + } ) ) pub = OpenSSL::PKey.read(kp.public_key.unpack1("m0")) @@ -153,8 +158,10 @@ def test_rpc_with_auth_context_signs_body_and_sends_sig_header ) captured_sig = nil + captured_expiry = nil assert_requested(:post, "#{BASE_URL}/v1/wallets/w-1/rpc") do |req| captured_sig = req.headers["Privy-Authorization-Signature"] + captured_expiry = req.headers["Privy-Request-Expiry"] refute(req.headers.key?("Privy-Idempotency-Key")) end @@ -163,7 +170,10 @@ def test_rpc_with_auth_context_signs_body_and_sends_sig_header method: :post, url: "#{BASE_URL}/v1/wallets/w-1/rpc", body: rpc_body, - headers: {"privy-app-id" => "app-abc"} + headers: { + "privy-app-id" => "app-abc", + "privy-request-expiry" => captured_expiry + } ) ) pub = OpenSSL::PKey.read(kp.public_key.unpack1("m0")) @@ -185,9 +195,11 @@ def test_rpc_with_auth_context_and_idempotency_signs_over_idempotency_header ) captured_sig = nil + captured_expiry = nil assert_requested(:post, "#{BASE_URL}/v1/wallets/w-1/rpc") do |req| assert_equal("idem-both", req.headers["Privy-Idempotency-Key"]) captured_sig = req.headers["Privy-Authorization-Signature"] + captured_expiry = req.headers["Privy-Request-Expiry"] end expected_payload = Privy::Authorization.format_request_for_authorization_signature( @@ -197,7 +209,8 @@ def test_rpc_with_auth_context_and_idempotency_signs_over_idempotency_header body: rpc_body, headers: { "privy-app-id" => "app-abc", - "privy-idempotency-key" => "idem-both" + "privy-idempotency-key" => "idem-both", + "privy-request-expiry" => captured_expiry } ) ) @@ -208,4 +221,116 @@ def test_rpc_with_auth_context_and_idempotency_signs_over_idempotency_header "signature must cover idempotency-key header" ) end + + # --- request_expiry on update ---------------------------------------------- + + def test_update_default_request_expiry_is_auto_set + stub_json(:patch, "#{BASE_URL}/v1/wallets/w-1", body: wallet_response_body(display_name: "n")) + before = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) + build_client.wallets.update("w-1", wallet_update_params: {display_name: "n"}) + assert_requested(:patch, "#{BASE_URL}/v1/wallets/w-1") do |req| + sent = req.headers["Privy-Request-Expiry"].to_i + assert_in_delta(before + (15 * 60 * 1_000), sent, 5_000) + end + end + + def test_update_per_call_request_expiry_overrides_default + stub_json(:patch, "#{BASE_URL}/v1/wallets/w-1", body: wallet_response_body(display_name: "n")) + build_client.wallets.update( + "w-1", + wallet_update_params: {display_name: "n"}, + request_expiry: 1_750_000_000_000 + ) + assert_requested(:patch, "#{BASE_URL}/v1/wallets/w-1") do |req| + assert_equal("1750000000000", req.headers["Privy-Request-Expiry"]) + end + end + + def test_update_disabled_options_omit_header + client = Privy::PrivyClient.new( + app_id: "app-abc", + app_secret: "secret", + environment: :staging, + request_expiry: Privy::PrivyRequestExpiryOptions.build(disabled: true) + ) + stub_json(:patch, "#{BASE_URL}/v1/wallets/w-1", body: wallet_response_body(display_name: "n")) + client.wallets.update("w-1", wallet_update_params: {display_name: "n"}) + assert_requested(:patch, "#{BASE_URL}/v1/wallets/w-1") do |req| + refute(req.headers.key?("Privy-Request-Expiry")) + end + end + + def test_update_disabled_options_still_honor_explicit_override + client = Privy::PrivyClient.new( + app_id: "app-abc", + app_secret: "secret", + environment: :staging, + request_expiry: Privy::PrivyRequestExpiryOptions.build(disabled: true) + ) + stub_json(:patch, "#{BASE_URL}/v1/wallets/w-1", body: wallet_response_body(display_name: "n")) + client.wallets.update( + "w-1", + wallet_update_params: {display_name: "n"}, + request_expiry: 1_750_000_000_000 + ) + assert_requested(:patch, "#{BASE_URL}/v1/wallets/w-1") do |req| + assert_equal("1750000000000", req.headers["Privy-Request-Expiry"]) + end + end + + # --- request_expiry on rpc / raw_sign / transfer --------------------------- + + RAW_SIGN_RESPONSE_BODY = { + method: "raw_sign", + data: {encoding: "hex", signature: "0xdeadbeef"} + }.to_json + + TRANSFER_RESPONSE_BODY = { + id: "wa-1", + created_at: "2026-01-01T00:00:00Z", + destination_address: "0x0000000000000000000000000000000000000001", + source_chain: "base", + status: "submitted", + type: "transfer", + wallet_id: "w-1" + }.to_json + + def test_rpc_per_call_request_expiry_overrides_default + stub_json(:post, "#{BASE_URL}/v1/wallets/w-1/rpc", body: RPC_RESPONSE_BODY) + build_client.wallets.rpc( + "w-1", + wallet_rpc_request_body: rpc_body, + request_expiry: 1_750_000_000_000 + ) + assert_requested(:post, "#{BASE_URL}/v1/wallets/w-1/rpc") do |req| + assert_equal("1750000000000", req.headers["Privy-Request-Expiry"]) + end + end + + def test_raw_sign_per_call_request_expiry_overrides_default + stub_json(:post, "#{BASE_URL}/v1/wallets/w-1/raw_sign", body: RAW_SIGN_RESPONSE_BODY) + build_client.wallets.raw_sign( + "w-1", + raw_sign_input: {params: {hash: "0x1234"}}, + request_expiry: 1_750_000_000_000 + ) + assert_requested(:post, "#{BASE_URL}/v1/wallets/w-1/raw_sign") do |req| + assert_equal("1750000000000", req.headers["Privy-Request-Expiry"]) + end + end + + def test_transfer_per_call_request_expiry_overrides_default + stub_json(:post, "#{BASE_URL}/v1/wallets/w-1/transfer", body: TRANSFER_RESPONSE_BODY) + build_client.wallets.transfer( + "w-1", + wallet_transfer_params: { + source: {asset: "usdc", amount: "1", chain: "base"}, + destination: {address: "0x0000000000000000000000000000000000000001"} + }, + request_expiry: 1_750_000_000_000 + ) + assert_requested(:post, "#{BASE_URL}/v1/wallets/w-1/transfer") do |req| + assert_equal("1750000000000", req.headers["Privy-Request-Expiry"]) + end + end end