Skip to content

Commit

Permalink
feat: Support funding wallets
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alex-stone committed Nov 22, 2024
1 parent c917190 commit edae57a
Show file tree
Hide file tree
Showing 8 changed files with 776 additions and 1 deletion.
1 change: 1 addition & 0 deletions lib/coinbase.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
14 changes: 14 additions & 0 deletions lib/coinbase/address/wallet_address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
216 changes: 216 additions & 0 deletions lib/coinbase/fund_operation.rb
Original file line number Diff line number Diff line change
@@ -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<Coinbase::FundOperation>] 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
8 changes: 7 additions & 1 deletion lib/coinbase/wallet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Coinbase::WalletAddress>] The addresses belonging to the Wallet
Expand Down
103 changes: 103 additions & 0 deletions spec/factories/fund_operation.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit edae57a

Please sign in to comment.