diff --git a/README.md b/README.md index ad023fa6..6b889e99 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,9 @@ testnet ETH. You are allowed one faucet claim per 24-hour window. # Fund the wallet with a faucet transaction. faucet_tx = wallet1.faucet +# Wait for the faucet transaction to complete. +faucet_tx.wait! + puts "Faucet transaction successfully completed: #{faucet_tx}" ``` 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