From b240f60904f9e2e6b367150a59650edefbdb3348 Mon Sep 17 00:00:00 2001 From: Alex Stone Date: Tue, 29 Oct 2024 12:28:01 -0700 Subject: [PATCH] feat: Support async faucet transactions This adds support for asynchronous faucet transactions. This will make faucet transactions consistent with other transactions and will make them more stable, as we can poll for the status on the SDK side rather than on the server side. ``` faucet_tx = wallet.faucet faucet_tx.wait! ``` or ``` wallet.faucet.wait! ``` --- lib/coinbase/address.rb | 11 +- lib/coinbase/faucet_transaction.rb | 68 ++++++++++- lib/coinbase/transaction.rb | 6 + spec/factories/faucet_transaction.rb | 40 +++++++ spec/factories/transaction.rb | 7 ++ .../shared_examples/address_balances.rb | 35 +++++- spec/unit/coinbase/faucet_transaction_spec.rb | 106 +++++++++++++++++- spec/unit/coinbase/smart_contract_spec.rb | 2 +- spec/unit/coinbase/transaction_spec.rb | 7 ++ spec/unit/coinbase/wallet_spec.rb | 16 ++- 10 files changed, 275 insertions(+), 23 deletions(-) create mode 100644 spec/factories/faucet_transaction.rb diff --git a/lib/coinbase/address.rb b/lib/coinbase/address.rb index 4e0d5a2a..d5837abd 100644 --- a/lib/coinbase/address.rb +++ b/lib/coinbase/address.rb @@ -91,11 +91,16 @@ def transactions # @raise [Coinbase::FaucetLimitReachedError] If the faucet limit has been reached for the address or user. # @raise [Coinbase::Client::ApiError] If an unexpected error occurs while requesting faucet funds. def faucet(asset_id: nil) - opts = { asset_id: asset_id }.compact - Coinbase.call_api do Coinbase::FaucetTransaction.new( - addresses_api.request_external_faucet_funds(network.normalized_id, id, opts) + addresses_api.request_external_faucet_funds( + network.normalized_id, + id, + { + asset_id: asset_id, + skip_wait: true + }.compact + ) ) end end diff --git a/lib/coinbase/faucet_transaction.rb b/lib/coinbase/faucet_transaction.rb index 8cdf6cc6..79fcebf2 100644 --- a/lib/coinbase/faucet_transaction.rb +++ b/lib/coinbase/faucet_transaction.rb @@ -12,22 +12,80 @@ def initialize(model) @model = model end + # Returns the Faucet transaction. + # @return [Coinbase::Transaction] The Faucet transaction + def transaction + @transaction ||= Coinbase::Transaction.new(@model.transaction) + end + + # Returns the status of the Faucet transaction. + # @return [Symbol] The status + def status + transaction.status + end + # Returns the transaction hash. # @return [String] The onchain transaction hash def transaction_hash - model.transaction_hash + transaction.transaction_hash end # Returns the link to the transaction on the blockchain explorer. # @return [String] The link to the transaction on the blockchain explorer def transaction_link - model.transaction_link + transaction.transaction_link + end + + # Returns the Network of the Transaction. + # @return [Coinbase::Network] The Network + def network + transaction.network + end + + # Waits until the FaucetTransaction is completed or failed by polling on the given interval. + # @param interval_seconds [Integer] The interval at which to poll the Network, in seconds + # @param timeout_seconds [Integer] The maximum amount of time to wait for the Transfer to complete, in seconds + # @raise [Timeout::Error] if the FaucetTransaction takes longer than the given timeout + # @return [Transfer] The completed Transfer object + def wait!(interval_seconds = 0.2, timeout_seconds = 20) + start_time = Time.now + + loop do + reload + + return self if transaction.terminal_state? + + raise Timeout::Error, 'Faucet transaction timed out' if Time.now - start_time > timeout_seconds + + self.sleep interval_seconds + end + + self + end + + def reload + @model = Coinbase.call_api do + addresses_api.get_faucet_transaction( + network.normalized_id, + transaction.to_address_id, + transaction_hash + ) + end + + @transaction = Coinbase::Transaction.new(@model.transaction) + + self end # Returns a String representation of the FaucetTransaction. # @return [String] a String representation of the FaucetTransaction def to_s - "Coinbase::FaucetTransaction{transaction_hash: '#{transaction_hash}', transaction_link: '#{transaction_link}'}" + Coinbase.pretty_print_object( + self.class, + status: transaction.status, + transaction_hash: transaction_hash, + transaction_link: transaction_link + ) end # Same as to_s. @@ -38,6 +96,8 @@ def inspect private - attr_reader :model + def addresses_api + @addresses_api ||= Coinbase::Client::ExternalAddressesApi.new(Coinbase.configuration.api_client) + end end end diff --git a/lib/coinbase/transaction.rb b/lib/coinbase/transaction.rb index 2d0f1653..b5edbfe3 100644 --- a/lib/coinbase/transaction.rb +++ b/lib/coinbase/transaction.rb @@ -41,6 +41,12 @@ def initialize(model) @model = model end + # Returns the Network of the Transaction. + # @return [Coinbase::Network] The Network + def network + @network ||= Coinbase::Network.from_id(@model.network_id) + end + # Returns the Unsigned Payload of the Transaction. # @return [String] The Unsigned Payload def unsigned_payload diff --git a/spec/factories/faucet_transaction.rb b/spec/factories/faucet_transaction.rb new file mode 100644 index 00000000..b824be33 --- /dev/null +++ b/spec/factories/faucet_transaction.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :faucet_tx_model, class: Coinbase::Client::FaucetTransaction do + transient do + status { 'broadcasted' } + network_trait { :base_sepolia } + to_address_id { nil } + transaction_hash { nil } + end + + # Default traits + base_sepolia + pending + + TX_TRAITS.each do |status| + trait status do + status { status } + end + end + + NETWORK_TRAITS.each do |network| + trait network do + network_trait { network } + end + end + + after(:build) do |transfer, transients| + transfer.transaction = build( + :transaction_model, + transients.status, + transients.network_trait, + { + to_address_id: transients.to_address_id, + transaction_hash: transients.transaction_hash + }.compact + ) + end + end +end diff --git a/spec/factories/transaction.rb b/spec/factories/transaction.rb index c0227e98..c43b81f3 100644 --- a/spec/factories/transaction.rb +++ b/spec/factories/transaction.rb @@ -10,6 +10,13 @@ # Default trait. pending + base_sepolia + + NETWORK_TRAITS.each do |network| + trait network do + network_id { Coinbase.normalize_network(network) } + end + end trait :pending do status { 'pending' } diff --git a/spec/support/shared_examples/address_balances.rb b/spec/support/shared_examples/address_balances.rb index f7ecd741..0d29d245 100644 --- a/spec/support/shared_examples/address_balances.rb +++ b/spec/support/shared_examples/address_balances.rb @@ -141,10 +141,10 @@ end describe '#faucet' do - let(:tx_hash) { '0xdeadbeef' } let(:faucet_tx) do - instance_double(Coinbase::Client::FaucetTransaction, transaction_hash: tx_hash) + build(:faucet_tx_model, network_id, :broadcasted, to_address_id: address_id) end + let(:tx_hash) { faucet_tx.transaction.transaction_hash } context 'when the request is successful' do subject(:faucet_response) { address.faucet } @@ -152,7 +152,7 @@ before do allow(external_addresses_api) .to receive(:request_external_faucet_funds) - .with(normalized_network_id, address_id, {}) + .with(normalized_network_id, address_id, { skip_wait: true }) .and_return(faucet_tx) end @@ -161,7 +161,7 @@ expect(external_addresses_api) .to have_received(:request_external_faucet_funds) - .with(normalized_network_id, address_id, {}) + .with(normalized_network_id, address_id, { skip_wait: true }) end it 'returns the faucet transaction' do @@ -173,11 +173,34 @@ end end - context 'when the request is unsuccesful' do + context 'when using specified asset' do + subject(:faucet_response) { address.faucet(asset_id: :usdc) } + + before do + allow(external_addresses_api) + .to receive(:request_external_faucet_funds) + .with(normalized_network_id, address_id, { asset_id: :usdc, skip_wait: true }) + .and_return(faucet_tx) + end + + it 'requests external faucet funds for the address for the specified asset' do + faucet_response + + expect(external_addresses_api) + .to have_received(:request_external_faucet_funds) + .with(normalized_network_id, address_id, { asset_id: :usdc, skip_wait: true }) + end + + it 'returns the faucet transaction' do + expect(faucet_response).to be_a(Coinbase::FaucetTransaction) + end + end + + context 'when the request is unsuccessful' do before do allow(external_addresses_api) .to receive(:request_external_faucet_funds) - .with(normalized_network_id, address_id, {}) + .with(normalized_network_id, address_id, { skip_wait: true }) .and_raise(api_error) end diff --git a/spec/unit/coinbase/faucet_transaction_spec.rb b/spec/unit/coinbase/faucet_transaction_spec.rb index e5566c16..82d0c5db 100644 --- a/spec/unit/coinbase/faucet_transaction_spec.rb +++ b/spec/unit/coinbase/faucet_transaction_spec.rb @@ -3,14 +3,29 @@ describe Coinbase::FaucetTransaction do subject(:faucet_transaction) { described_class.new(model) } + let(:network_id) { :base_sepolia } let(:transaction_hash) { '0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11' } let(:transaction_link) { "https://sepolia.basescan.org/tx/#{transaction_hash}" } - let(:model) do - Coinbase::Client::FaucetTransaction.new( + let(:address_id) { Eth::Key.new.address.to_s } + let(:transaction_model) do + build( + :transaction_model, + :broadcasted, + network_id, + to_address_id: address_id, transaction_hash: transaction_hash, transaction_link: transaction_link ) end + let(:model) do + Coinbase::Client::FaucetTransaction.new(transaction: transaction_model) + end + + let(:external_addresses_api) { instance_double(Coinbase::Client::ExternalAddressesApi) } + + before do + allow(Coinbase::Client::ExternalAddressesApi).to receive(:new).and_return(external_addresses_api) + end describe '#initialize' do it 'initializes a new FaucetTransaction' do @@ -18,22 +33,105 @@ end end + describe '#transaction' do + it 'returns the transaction' do + expect(faucet_transaction.transaction).to be_a(Coinbase::Transaction) + end + end + describe '#transaction_hash' do it 'returns the transaction hash' do expect(faucet_transaction.transaction_hash).to eq(transaction_hash) end end + describe '#status' do + it 'returns the transaction status' do + expect(faucet_transaction.status).to eq(Coinbase::Transaction::Status::BROADCAST) + end + end + + describe '#transaction_link' do it 'returns the transaction link' do expect(faucet_transaction.transaction_link).to eq(transaction_link) end end + describe '#network' do + it 'returns the network' do + expect(faucet_transaction.network).to be_a(Coinbase::Network) + end + end + + describe '#reload' do + let(:updated_transaction_model) do + build( + :transaction_model, + :completed, + to_address_id: address_id, + transaction_hash: transaction_hash, + transaction_link: transaction_link + ) + end + let(:updated_model) do + Coinbase::Client::FaucetTransaction.new(transaction: updated_transaction_model) + end + + before do + allow(external_addresses_api) + .to receive(:get_faucet_transaction) + .with('base-sepolia', address_id, transaction_hash) + .and_return(updated_model) + end + + it 'updates the faucet transaction' do + expect(faucet_transaction.reload.transaction.status).to eq(Coinbase::Transaction::Status::COMPLETE) + end + end + + describe '#wait!' do + before do + allow(faucet_transaction).to receive(:sleep) # rubocop:disable RSpec/SubjectStub + + allow(external_addresses_api) + .to receive(:get_faucet_transaction) + .with('base-sepolia', address_id, transaction_hash) + .and_return(model, model, updated_model) + end + + context 'when the faucet transaction is completed' do + let(:updated_model) { build(:faucet_tx_model, network_id, :completed) } + + it 'returns the completed FaucetTransaction' do + expect(faucet_transaction.wait!.status).to eq(Coinbase::Transaction::Status::COMPLETE) + end + end + + context 'when the faucet transaction is failed' do + let(:updated_model) { build(:faucet_tx_model, network_id, :failed) } + + it 'returns the failed FaucetTransaction' do + expect(faucet_transaction.wait!.status).to eq(Coinbase::Transaction::Status::FAILED) + end + end + + context 'when the faucet transaction times out' do + let(:updated_model) { build(:faucet_tx_model, network_id, :broadcasted) } + + it 'raises a Timeout::Error' do + expect { faucet_transaction.wait!(0.2, 0.00001) }.to raise_error(Timeout::Error, 'Faucet transaction timed out') + end + end + end + describe '#to_s' do it 'returns a string representation of the FaucetTransaction' do - expect(faucet_transaction.to_s).to eq( - "Coinbase::FaucetTransaction{transaction_hash: '#{transaction_hash}', transaction_link: '#{transaction_link}'}" + expect(faucet_transaction.to_s).to include( + 'Coinbase::FaucetTransaction', + transaction_hash, + transaction_link, + 'broadcast' ) end end diff --git a/spec/unit/coinbase/smart_contract_spec.rb b/spec/unit/coinbase/smart_contract_spec.rb index feff0879..40d447de 100644 --- a/spec/unit/coinbase/smart_contract_spec.rb +++ b/spec/unit/coinbase/smart_contract_spec.rb @@ -866,7 +866,7 @@ def build_nested_solidity_value(hash) end describe '#inspect' do - it 'includes smart contractdetails' do + it 'includes smart contract details' do expect(smart_contract.inspect).to include( address_id, Coinbase.to_sym(network_id).to_s, diff --git a/spec/unit/coinbase/transaction_spec.rb b/spec/unit/coinbase/transaction_spec.rb index c03e8dc2..e0ffb9c9 100644 --- a/spec/unit/coinbase/transaction_spec.rb +++ b/spec/unit/coinbase/transaction_spec.rb @@ -3,6 +3,7 @@ describe Coinbase::Transaction do subject(:transaction) { build(:transaction, model: transaction_model) } + let(:network_id) { :base_sepolia } let(:from_key) { build(:key) } let(:to_address_id) { '0xe317065De795eFBaC71cf00114c7252BFcd23c29'.downcase } let(:transaction_model) { build(:transaction_model, from_address_id: from_key.address.to_s) } @@ -21,6 +22,12 @@ end end + describe '#network' do + it 'returns the network' do + expect(transaction.network.id).to eq(network_id) + end + end + describe '#unsigned_payload' do it 'returns the unsigned payload' do expect(transaction.unsigned_payload).to eq(transaction_model.unsigned_payload) diff --git a/spec/unit/coinbase/wallet_spec.rb b/spec/unit/coinbase/wallet_spec.rb index be9a271b..32e02e7f 100644 --- a/spec/unit/coinbase/wallet_spec.rb +++ b/spec/unit/coinbase/wallet_spec.rb @@ -1190,8 +1190,14 @@ def match_create_address_request(req, expected_public_key, expected_address_inde end describe '#faucet' do + let(:tx_hash) { SecureRandom.hex(32) } let(:faucet_transaction_model) do - Coinbase::Client::FaucetTransaction.new({ transaction_hash: '0x123456789' }) + build( + :faucet_tx_model, + :broadcasted, + transaction_hash: tx_hash, + to_address_id: first_address_model.address_id + ) end let(:wallet) { described_class.new(model_with_default_address, seed: '') } @@ -1206,7 +1212,7 @@ def match_create_address_request(req, expected_public_key, expected_address_inde allow(external_addresses_api) .to receive(:request_external_faucet_funds) - .with(normalized_network_id, first_address_model.address_id, {}) + .with(normalized_network_id, first_address_model.address_id, { skip_wait: true }) .and_return(faucet_transaction_model) end @@ -1215,7 +1221,7 @@ def match_create_address_request(req, expected_public_key, expected_address_inde end it 'contains the transaction hash' do - expect(faucet_transaction.transaction_hash).to eq(faucet_transaction_model.transaction_hash) + expect(faucet_transaction.transaction_hash).to eq(tx_hash) end end @@ -1230,7 +1236,7 @@ def match_create_address_request(req, expected_public_key, expected_address_inde allow(external_addresses_api) .to receive(:request_external_faucet_funds) - .with(normalized_network_id, first_address_model.address_id, { asset_id: :usdc }) + .with(normalized_network_id, first_address_model.address_id, { asset_id: :usdc, skip_wait: true }) .and_return(faucet_transaction_model) end @@ -1239,7 +1245,7 @@ def match_create_address_request(req, expected_public_key, expected_address_inde end it 'contains the transaction hash' do - expect(faucet_transaction.transaction_hash).to eq(faucet_transaction_model.transaction_hash) + expect(faucet_transaction.transaction_hash).to eq(tx_hash) end end end