From edae57a15091c32d86a8a40cbc9db0f0ee076ddf Mon Sep 17 00:00:00 2001 From: Alex Stone Date: Fri, 25 Oct 2024 09:20:33 -0700 Subject: [PATCH] feat: Support funding wallets This adds support for funding wallets using `wallet.fund` or `address.fund`. This will be extended to support leveraging a fund quote in a subsequent commit. --- lib/coinbase.rb | 1 + lib/coinbase/address/wallet_address.rb | 14 + lib/coinbase/fund_operation.rb | 216 ++++++++++ lib/coinbase/wallet.rb | 8 +- spec/factories/fund_operation.rb | 103 +++++ .../coinbase/address/wallet_address_spec.rb | 29 ++ spec/unit/coinbase/fund_operation_spec.rb | 380 ++++++++++++++++++ spec/unit/coinbase/wallet_spec.rb | 26 ++ 8 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 lib/coinbase/fund_operation.rb create mode 100644 spec/factories/fund_operation.rb create mode 100644 spec/unit/coinbase/fund_operation_spec.rb diff --git a/lib/coinbase.rb b/lib/coinbase.rb index 1577261b..559695a0 100644 --- a/lib/coinbase.rb +++ b/lib/coinbase.rb @@ -20,6 +20,7 @@ require_relative 'coinbase/fiat_amount' require_relative 'coinbase/middleware' require_relative 'coinbase/network' +require_relative 'coinbase/fund_operation' require_relative 'coinbase/fund_quote' require_relative 'coinbase/pagination' require_relative 'coinbase/payload_signature' diff --git a/lib/coinbase/address/wallet_address.rb b/lib/coinbase/address/wallet_address.rb index d096b68a..6fa19ecd 100644 --- a/lib/coinbase/address/wallet_address.rb +++ b/lib/coinbase/address/wallet_address.rb @@ -217,6 +217,20 @@ def sign_payload(unsigned_payload:) ) end + # Funds the address from your account on the Coinbase Platform for the given amount of the given Asset. + # @param amount [Integer, Float, BigDecimal] The amount of the Asset to fund the wallet with. + # @param asset_id [Symbol] The ID of the Asset to trade from. For Ether, :eth, :gwei, and :wei are supported. + # @return [Coinbase::FundOperation] The FundOperation object. + def fund(amount, asset_id) + FundOperation.create( + address_id: id, + amount: amount, + asset_id: asset_id, + network: network, + wallet_id: wallet_id + ) + end + # Gets a quote for a fund operation to fund the address from your Coinbase platform, # account for the amount of the specified Asset. # @param asset_id [Symbol] The ID of the Asset to trade from. For Ether, :eth, :gwei, and :wei are supported. diff --git a/lib/coinbase/fund_operation.rb b/lib/coinbase/fund_operation.rb new file mode 100644 index 00000000..26bcfa8f --- /dev/null +++ b/lib/coinbase/fund_operation.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require_relative 'constants' +require 'bigdecimal' +require 'eth' + +module Coinbase + # A representation of a Fund Operation, which buys funds from the Coinbase platform, + # and sends then to the developer's address. + class FundOperation + # A representation of a Fund Operation status. + module Status + # The Fund Operation is being processed. + PENDING = 'pending' + + # The Fund Operation is complete. + COMPLETE = 'complete' + + # The Fund Operation has failed for some reason. + FAILED = 'failed' + + # The states that are considered terminal on-chain. + TERMINAL_STATES = [COMPLETE, FAILED].freeze + end + + class << self + # Creates a new Fund Operation object. + # @param address_id [String] The Address ID of the sending Address + # @param wallet_id [String] The Wallet ID of the sending Wallet + # @param amount [BigDecimal] The amount of the Asset to send + # @param network [Coinbase::Network, Symbol] The Network or Network ID of the Asset + # @param asset_id [Symbol] The Asset ID of the Asset to send + # @return [FundOperation] The new pending FundOperation object + # @raise [Coinbase::ApiError] If the FundOperation fails + def create(wallet_id:, address_id:, amount:, asset_id:, network:) + network = Coinbase::Network.from_id(network) + asset = network.get_asset(asset_id) + + model = Coinbase.call_api do + fund_api.create_fund_operation( + wallet_id, + address_id, + { + amount: asset.to_atomic_amount(amount).to_i.to_s, + asset_id: asset.primary_denomination.to_s, + } + ) + end + + new(model) + end + + # Enumerates the fund operation for a given address belonging to a wallet. + # The result is an enumerator that lazily fetches from the server, and can be iterated over, + # converted to an array, etc... + # @return [Enumerable] Enumerator that returns fund operations + def list(wallet_id:, address_id:) + Coinbase::Pagination.enumerate( + ->(page) { fetch_page(wallet_id, address_id, page) } + ) do |fund_operation| + new(fund_operation) + end + end + + private + + def fund_api + Coinbase::Client::FundApi.new(Coinbase.configuration.api_client) + end + + def fetch_page(wallet_id, address_id, page) + fund_api.list_fund_operations( + wallet_id, + address_id, + limit: DEFAULT_PAGE_LIMIT, + page: page + ) + end + end + + # Returns a new Fund Operation object. Do not use this method directly. Instead, use + # Wallet#fund or Address#fund. + # @param model [Coinbase::Client::FundOperation] The underlying Fund Operation object + def initialize(model) + raise ArgumentError, 'must be a FundOperation' unless model.is_a?(Coinbase::Client::FundOperation) + + @model = model + end + + # Returns the Fund Operation ID. + # @return [String] The Fund Operation ID + def id + @model.fund_operation_id + end + + # Returns the Network of the Fund Operation. + # @return [Coinbase::Network] The Network + def network + @network ||= Coinbase::Network.from_id(@model.network_id) + end + + # Returns the Wallet ID that the fund quote was created for. + # @return [String] The Wallet ID + def wallet_id + @model.wallet_id + end + + # Returns the Address ID that the fund quote was created for. + # @return [String] The Address ID + def address_id + @model.address_id + end + + # Returns the Asset of the Fund Operation. + # @return [Coinbase::Asset] The Asset + def asset + amount.asset + end + + # Returns the amount that the wallet will receive in crypto. + # @return [Coinbase::CryptoAmount] The crypto amount + def amount + @amount ||= CryptoAmount.from_model(@model.crypto_amount) + end + + # Returns the amount that the wallet's owner will pay in fiat. + # @return [Coinbase::FiatAmount] The fiat amount + def fiat_amount + @fiat_amount ||= FiatAmount.from_model(@model.fiat_amount) + end + + # Returns the fee that the wallet's owner will pay in fiat. + # @return [Coinbase::FiatAmount] The fiat buy fee + def buy_fee + @buy_fee ||= FiatAmount.from_model(@model.fees.buy_fee) + end + + # Returns the fee that the wallet's owner will pay in crypto. + # @return [Coinbase::CryptoAmount] The crypto transfer fee + def transfer_fee + @transfer_fee ||= CryptoAmount.from_model(@model.fees.transfer_fee) + end + + # Returns the status of the Fund Operation. + # @return [Symbol] The status + def status + @model.status + end + + # Reload reloads the Transfer model with the latest version from the server side. + # @return [Transfer] The most recent version of Transfer from the server. + def reload + @model = Coinbase.call_api do + fund_api.get_fund_operation(wallet_id, address_id, id) + end + + self + end + + # Waits until the Fund Operation is completed or failed by polling the at 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 Fund Operation to complete, in seconds + # @return [Coinbase::FundOperation] The completed or failed Fund Operation object + # @raise [Timeout::Error] If the Fund Operation takes longer than the given timeout + def wait!(interval_seconds = 1, timeout_seconds = 30) + start_time = Time.now + + loop do + reload + + return self if terminal_state? + + raise Timeout::Error, 'Fund Operation timed out' if Time.now - start_time > timeout_seconds + + self.sleep interval_seconds + end + + self + end + + # Returns a String representation of the Fund Operation. + # @return [String] a String representation of the Fund Operation + def to_s + Coinbase.pretty_print_object( + self.class, + id: id, + network_id: network.id, + wallet_id: wallet_id, + address_id: address_id, + status: status, + crypto_amount: amount, + fiat_amount: fiat_amount, + buy_fee: buy_fee, + transfer_fee: transfer_fee + ) + end + + # Same as to_s. + # @return [String] a String representation of the Fund Operation. + def inspect + to_s + end + + private + + # Returns whether the Fund Operation is in a terminal state. + # @return [Boolean] Whether the Fund Operation is in a terminal state + def terminal_state? + Status::TERMINAL_STATES.include?(status) + end + + def fund_api + @fund_api ||= Coinbase::Client::FundApi.new(Coinbase.configuration.api_client) + end + end +end diff --git a/lib/coinbase/wallet.rb b/lib/coinbase/wallet.rb index ea8e3789..10b51a4a 100644 --- a/lib/coinbase/wallet.rb +++ b/lib/coinbase/wallet.rb @@ -264,9 +264,15 @@ def initialize(model, seed: nil) # @param asset_id [Symbol] The ID of the Asset to trade from. For Ether, :eth, :gwei, and :wei are supported. # @return [Coinbase::FundQuote] The FundQuote object. + # @!method fund + # Funds the address from your account on the Coinbase Platform for the given amount of the given Asset. + # @param amount [Integer, Float, BigDecimal] The amount of the Asset to fund the wallet with. + # @param asset_id [Symbol] The ID of the Asset to trade from. For Ether, :eth, :gwei, and :wei are supported. + # @return [Coinbase::FundOperation] The FundOperation object. + def_delegators :default_address, :transfer, :trade, :faucet, :stake, :unstake, :claim_stake, :staking_balances, :stakeable_balance, :unstakeable_balance, :claimable_balance, :sign_payload, :invoke_contract, - :deploy_token, :deploy_nft, :deploy_multi_token, :quote_fund + :deploy_token, :deploy_nft, :deploy_multi_token, :quote_fund, :fund # Returns the addresses belonging to the Wallet. # @return [Array] The addresses belonging to the Wallet diff --git a/spec/factories/fund_operation.rb b/spec/factories/fund_operation.rb new file mode 100644 index 00000000..6243936f --- /dev/null +++ b/spec/factories/fund_operation.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +FUND_OPERATION_STATES = %i[pending complete failed].freeze + +FactoryBot.define do + factory :fund_operation_model, class: Coinbase::Client::FundOperation do + transient do + key { build(:key) } + whole_amount { 123 } + buy_fee { build(:fiat_amount_model) } + transfer_fee { build(:crypto_amount_model, Coinbase.to_sym(network_id)) } + end + + wallet_id { SecureRandom.uuid } + fund_operation_id { SecureRandom.uuid } + address_id { key.address.to_s } + crypto_amount { build(:crypto_amount_model, Coinbase.to_sym(network_id), whole_amount: whole_amount) } + + fiat_amount { build(:fiat_amount_model) } + + # Default traits + base_sepolia + eth + pending + + FUND_OPERATION_STATES.each do |status| + trait status do + status { status.to_s } + end + end + + NETWORK_TRAITS.each do |network| + trait network do + network_id { Coinbase.normalize_network(network) } + end + end + + ASSET_TRAITS.each do |asset| + trait asset do + crypto_amount do + build(:crypto_amount_model, Coinbase.to_sym(network_id), asset, whole_amount: whole_amount) + end + end + end + + after(:build) do |fund_operation, transients| + transients.transfer_fee.asset.network_id = transients.network_id + fund_operation.fees = Coinbase::Client::FundOperationFees.new( + buy_fee: transients.buy_fee, + transfer_fee: transients.transfer_fee + ) + end + end + + factory :fund_operation, class: Coinbase::FundOperation do + initialize_with { new(model) } + + transient do + network { nil } + asset { nil } + status { nil } + key { build(:key) } + amount { nil } + fiat_amount { build(:fiat_amount_model) } + end + + model { build(:fund_operation_model, key: key, crypto_amount: amount) } + + FUND_OPERATION_STATES.each do |status| + trait status do + status { status } + end + end + + NETWORK_TRAITS.each do |network| + trait network do + network { network } + end + end + + ASSET_TRAITS.each do |asset| + trait asset do + asset { asset } + end + end + + before(:build) do |transfer, transients| + transients.amount = build(:crypto_amount_model, network, whole_amount: 123) if transients.amount.nil? + + transfer.model do + build( + :fund_operation_model, + transients, + **{ + key: transients.key, + crypto_amount: transients.amount, + fiat_amount: transients.fiat_amount + }.compact + ) + end + end + end +end diff --git a/spec/unit/coinbase/address/wallet_address_spec.rb b/spec/unit/coinbase/address/wallet_address_spec.rb index d6ffc6a2..382591e8 100644 --- a/spec/unit/coinbase/address/wallet_address_spec.rb +++ b/spec/unit/coinbase/address/wallet_address_spec.rb @@ -555,6 +555,35 @@ end end + describe '#fund' do + subject(:fund_operation) { address.fund(amount, asset_id) } + + let(:amount) { '123.45' } + let(:asset_id) { :eth } + let(:crypto_amount) { build(:crypto_amount, network_id, asset_id, whole_amount: amount) } + let(:created_fund_operation) { build(:fund_operation, network_id, key: key, amount: crypto_amount) } + + before do + allow(Coinbase::FundOperation).to receive(:create).and_return(created_fund_operation) + + fund_operation + end + + it 'returns the created fund operation' do + expect(fund_operation).to eq(created_fund_operation) + end + + it 'creates the fund operation' do + expect(Coinbase::FundOperation).to have_received(:create).with( + address_id: address_id, + amount: amount, + asset_id: asset_id, + network: network, + wallet_id: wallet_id + ) + end + end + describe '#quote_fund' do subject(:fund_quote) { address.quote_fund(amount, asset_id) } diff --git a/spec/unit/coinbase/fund_operation_spec.rb b/spec/unit/coinbase/fund_operation_spec.rb new file mode 100644 index 00000000..48b35680 --- /dev/null +++ b/spec/unit/coinbase/fund_operation_spec.rb @@ -0,0 +1,380 @@ +# frozen_string_literal: true + +describe Coinbase::FundOperation do + subject(:fund_operation) { described_class.new(model) } + + let(:network_id) { :base_sepolia } + let(:whole_amount) { '123.45' } + let(:crypto_amount_model) do + build(:crypto_amount_model, network_id, whole_amount: whole_amount) + end + let(:crypto_amount) { build(:crypto_amount, model: crypto_amount_model) } + let(:fiat_amount_model) { build(:fiat_amount_model) } + let(:buy_fee_model) { build(:fiat_amount_model, amount: '0.45') } + let(:transfer_fee_model) { build(:crypto_amount_model, network_id, :eth, whole_amount: '1.01') } + let(:transfer_fee) { build(:crypto_amount, model: transfer_fee_model) } + let(:key) { build(:key) } + let(:address_id) { key.address.to_s } + let(:model) do + build( + :fund_operation_model, + network_id, + key: key, + crypto_amount: crypto_amount_model, + fiat_amount: fiat_amount_model, + buy_fee: buy_fee_model, + transfer_fee: transfer_fee_model + ) + end + let(:eth_asset) { build(:asset, network_id, :eth) } + + let(:fund_api) { instance_double(Coinbase::Client::FundApi) } + + before do + allow(Coinbase::Client::FundApi).to receive(:new).and_return(fund_api) + + allow(Coinbase::Asset) + .to receive(:fetch) + .with(network_id, :eth) + .and_return(eth_asset) + allow(Coinbase::Asset) + .to receive(:fetch) + .with(network_id, :gwei) + .and_return(build(:asset, network_id, :gwei)) + allow(Coinbase::Asset) + .to receive(:from_model) + .with(satisfy { |model| model.asset_id == 'eth' }) + .and_return(eth_asset) + end + + describe '.create' do + subject(:created_operation) do + described_class.create( + wallet_id: wallet_id, + address_id: address_id, + amount: amount, + asset_id: asset_id, + network: network_id + ) + end + + let(:wallet_id) { SecureRandom.uuid } + let(:asset_id) { eth_asset.asset_id } + let(:amount) { 123.45 } + let(:expected_atomic_amount) { '123450000000000000000' } + + before do + allow(fund_api).to receive(:create_fund_operation).and_return(model) + end + + it 'creates a fund operation' do + expect(created_operation).to be_a(described_class) + end + + it 'creates the fund operation via the API' do + created_operation + + expect(fund_api).to have_received(:create_fund_operation).with( + wallet_id, + address_id, + { + asset_id: 'eth', + amount: expected_atomic_amount + } + ) + end + + context 'when a quote is provided' do + subject(:created_operation) do + described_class.create( + wallet_id: wallet_id, + address_id: address_id, + amount: amount, + asset_id: asset_id, + network: network_id, + quote: quote + ) + end + + let(:quote) { build(:fund_quote, network_id, :eth) } + + it 'creates a fund operation' do + expect(created_operation).to be_a(described_class) + end + + it 'creates the fund operation for the specified quote via the API' do + created_operation + + expect(fund_api).to have_received(:create_fund_operation).with( + wallet_id, + address_id, + { + asset_id: 'eth', + amount: expected_atomic_amount, + fund_quote_id: quote.id + } + ) + end + end + + context 'when a quote ID is provided' do + subject(:created_operation) do + described_class.create( + wallet_id: wallet_id, + address_id: address_id, + amount: amount, + asset_id: asset_id, + network: network_id, + quote: quote_id + ) + end + + let(:quote_id) { SecureRandom.uuid } + + it 'creates a fund operation' do + expect(created_operation).to be_a(described_class) + end + + it 'creates the fund operation for the specified quote ID via the API' do + created_operation + + expect(fund_api).to have_received(:create_fund_operation).with( + wallet_id, + address_id, + { + asset_id: 'eth', + amount: expected_atomic_amount, + fund_quote_id: quote_id + } + ) + end + end + + context 'when an invalid quote is provided' do + subject(:created_operation) do + described_class.create( + wallet_id: wallet_id, + address_id: address_id, + amount: amount, + asset_id: asset_id, + network: network_id, + quote: build(:balance_model, network_id) + ) + end + + it 'raises an error' do + expect do + created_operation + end.to raise_error(ArgumentError, 'quote must be a FundQuote object or ID') + end + end + + context 'when the asset is not a primary denomination' do + let(:asset_id) { :gwei } + let(:amount) { 4567.89 } + let(:expected_atomic_amount) { '4567890000000' } + + it 'creates a fund operation' do + expect(created_operation).to be_a(described_class) + end + + it 'creates the fund operation in the primary denomination' do + created_operation + + expect(fund_api).to have_received(:create_fund_operation).with( + wallet_id, + address_id, + { + asset_id: 'eth', + amount: expected_atomic_amount + } + ) + end + end + end + + describe '.list' do + subject(:enumerator) do + described_class.list(wallet_id: wallet_id, address_id: address_id) + end + + let(:api) { fund_api } + let(:wallet_id) { SecureRandom.uuid } + let(:fetch_params) { ->(page) { [wallet_id, address_id, { limit: 100, page: page }] } } + let(:resource_list_klass) { Coinbase::Client::FundOperationList } + let(:item_klass) { described_class } + let(:item_initialize_args) { nil } + let(:create_model) do + ->(id) { build(:fund_operation_model, network_id, fund_operation_id: id) } + end + + it_behaves_like 'it is a paginated enumerator', :fund_operations + end + + describe '#initialize' do + it 'initializes a Fund operation' do + expect(fund_operation).to be_a(described_class) + end + + context 'when the model is not a Fund operation model' do + let(:model) { build(:balance_model) } + + it 'raises an error' do + expect { fund_operation }.to raise_error(ArgumentError) + end + end + end + + describe '#id' do + it 'returns the ID of the Fund operation' do + expect(fund_operation.id).to eq(model.fund_operation_id) + end + end + + describe '#network' do + it 'returns the network' do + expect(fund_operation.network.id).to eq(network_id) + end + end + + describe '#wallet_id' do + it 'returns the wallet ID' do + expect(fund_operation.wallet_id).to eq(model.wallet_id) + end + end + + describe '#address_id' do + it 'returns the address ID' do + expect(fund_operation.address_id).to eq(address_id) + end + end + + describe '#asset' do + it 'returns the asset' do + expect(fund_operation.asset.asset_id).to eq(crypto_amount.asset.asset_id) + end + end + + describe '#amount' do + it 'returns the crypto amount' do + expect(fund_operation.amount).to be_a(Coinbase::CryptoAmount) + end + + it 'returns the correct amount' do + expect(fund_operation.amount.amount).to eq(crypto_amount.amount) + end + + it 'returns the correct asset' do + expect(fund_operation.amount.asset.asset_id).to eq(:eth) + end + end + + describe '#fiat_amount' do + it 'returns the fiat amount' do + expect(fund_operation.fiat_amount).to be_a(Coinbase::FiatAmount) + end + + it 'returns the correct amount' do + expect(fund_operation.fiat_amount.amount).to eq(BigDecimal(fiat_amount_model.amount)) + end + + it 'returns the correct currency' do + expect(fund_operation.fiat_amount.currency).to eq(fiat_amount_model.currency.to_sym) + end + end + + describe '#buy_fee' do + it 'returns a fiat amount' do + expect(fund_operation.buy_fee).to be_a(Coinbase::FiatAmount) + end + + it 'returns the correct amount' do + expect(fund_operation.buy_fee.amount).to eq(BigDecimal(buy_fee_model.amount)) + end + + it 'returns the correct currency' do + expect(fund_operation.buy_fee.currency).to eq(buy_fee_model.currency.to_sym) + end + end + + describe '#transfer_fee' do + it 'returns a crypto amount' do + expect(fund_operation.transfer_fee).to be_a(Coinbase::CryptoAmount) + end + + it 'returns the correct amount' do + expect(fund_operation.transfer_fee.amount).to eq(transfer_fee.amount) + end + + it 'returns the correct asset' do + expect(fund_operation.transfer_fee.asset.asset_id).to eq(:eth) + end + end + + describe '#reload' do + let(:updated_model) { build(:fund_operation_model, network_id, :complete) } + + before do + allow(fund_api) + .to receive(:get_fund_operation) + .with(fund_operation.wallet_id, fund_operation.address_id, fund_operation.id) + .and_return(updated_model) + end + + it 'updates the fund operation' do + expect(fund_operation.reload.status).to eq(Coinbase::FundOperation::Status::COMPLETE) + end + end + + describe '#wait!' do + before do + allow(fund_operation).to receive(:sleep) # rubocop:disable RSpec/SubjectStub + + allow(fund_api) + .to receive(:get_fund_operation) + .with(fund_operation.wallet_id, fund_operation.address_id, fund_operation.id) + .and_return(model, model, updated_model) + end + + context 'when the fund operation is complete' do + let(:updated_model) { build(:fund_operation_model, network_id, :complete) } + + it 'returns the completed FundOperation' do + expect(fund_operation.wait!.status).to eq(Coinbase::Transaction::Status::COMPLETE) + end + end + + context 'when the fund operation is failed' do + let(:updated_model) { build(:fund_operation_model, network_id, :failed) } + + it 'returns the failed FundOperation' do + expect(fund_operation.wait!.status).to eq(Coinbase::Transaction::Status::FAILED) + end + end + + context 'when the fund operation times out' do + let(:updated_model) { build(:fund_operation_model, network_id, :pending) } + + it 'raises a Timeout::Error' do + expect { fund_operation.wait!(0.2, 0.00001) }.to raise_error(Timeout::Error, 'Fund Operation timed out') + end + end + end + + describe '#inspect' do + it 'includes fund operation details' do # rubocop:disable RSpec/ExampleLength + expect(fund_operation.inspect).to include( + Coinbase.to_sym(network_id).to_s, + model.fund_operation_id, + model.wallet_id, + address_id, + crypto_amount.to_s, + BigDecimal(fiat_amount_model.amount).to_i.to_s, + fiat_amount_model.currency, + BigDecimal(buy_fee_model.amount).to_i.to_s, + buy_fee_model.currency, + transfer_fee.to_s + ) + end + end +end diff --git a/spec/unit/coinbase/wallet_spec.rb b/spec/unit/coinbase/wallet_spec.rb index 161b4035..6a663bb4 100644 --- a/spec/unit/coinbase/wallet_spec.rb +++ b/spec/unit/coinbase/wallet_spec.rb @@ -1157,6 +1157,32 @@ def match_create_address_request(req, expected_public_key, expected_address_inde end end + describe '#fund' do + subject(:wallet) do + described_class.new(model_with_default_address, seed: '') + end + + let(:fund_operation) { build(:fund_operation, network_id) } + let(:amount) { '123.45' } + let(:asset_id) { :eth } + + before do + allow(addresses_api) + .to receive(:list_addresses) + .with(wallet_id, { limit: 20 }) + .and_return(Coinbase::Client::AddressList.new(data: [first_address_model], total_count: 1)) + + allow(wallet.default_address) + .to receive(:fund) + .with(amount, asset_id) + .and_return(fund_operation) + end + + it 'creates a fund operation using the default address' do + expect(wallet.fund(amount, asset_id)).to eq(fund_operation) + end + end + describe '#quote_fund' do subject(:wallet) do described_class.new(model_with_default_address, seed: '')