diff --git a/CHANGELOG.md b/CHANGELOG.md index 952c44b..5c7ff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Trade +## [0.0.4] - Pending + +* Refactor methods to be more idiomatic for Ruby. + ## [0.0.3] - 2024-05-08 ### Added diff --git a/README.md b/README.md index 2538314..2b4ddef 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Coinbase.configure do |config| end ``` -Another way to initialize the SDK is by sourcing the API key from the json file that contains your API key, +Another way to initialize the SDK is by sourcing the API key from the json file that contains your API key, downloaded from CDP portal. ```ruby @@ -151,7 +151,7 @@ u.save_wallet(w3) ``` To encrypt the saved data, set encrypt to true. Note that your CDP API key also serves as the encryption key -for the data persisted locally. To re-instantiate wallets with encrypted data, ensure that your SDK is configured with +for the data persisted locally. To re-instantiate wallets with encrypted data, ensure that your SDK is configured with the same API key when invoking `save_wallet` and `load_wallets`. ```ruby @@ -163,7 +163,7 @@ The below code demonstrates how to re-instantiate a Wallet from the data export. ```ruby # The Wallet can be re-instantiated using the exported data. # w4 will be equivalent to w3. -w4 = u.import_wallet(data) +w4 = Coinbase::Wallet.import(data) ``` To import wallets that were persisted to your local file system using `save_wallet`, use the below code. @@ -171,7 +171,7 @@ To import wallets that were persisted to your local file system using `save_wall # The Wallet can be re-instantiated using the exported data. # w5 will be equivalent to w3. wallets = u.load_wallets -w5 = wallets[w3.wallet_id] +w5 = wallets[w3.id] ``` ## Development diff --git a/lib/coinbase.rb b/lib/coinbase.rb index e5221ca..5031bd4 100644 --- a/lib/coinbase.rb +++ b/lib/coinbase.rb @@ -3,6 +3,7 @@ require_relative 'coinbase/address' require_relative 'coinbase/asset' require_relative 'coinbase/authenticator' +require_relative 'coinbase/balance' require_relative 'coinbase/balance_map' require_relative 'coinbase/client' require_relative 'coinbase/constants' @@ -18,7 +19,6 @@ # The Coinbase SDK. module Coinbase class InvalidConfiguration < StandardError; end - class FaucetLimitReached < StandardError; end # Returns the configuration object. # @return [Configuration] the configuration object @@ -100,30 +100,6 @@ def self.to_sym(value) value.to_s.gsub('-', '_').to_sym end - # Converts a Coinbase::Client::AddressBalanceList to a BalanceMap. - # @param address_balance_list [Coinbase::Client::AddressBalanceList] The AddressBalanceList to convert - # @return [BalanceMap] The converted BalanceMap - def self.to_balance_map(address_balance_list) - balances = {} - - address_balance_list.data.each do |balance| - asset_id = Coinbase.to_sym(balance.asset.asset_id.downcase) - amount = case asset_id - when :eth - BigDecimal(balance.amount) / BigDecimal(Coinbase::WEI_PER_ETHER) - when :usdc - BigDecimal(balance.amount) / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC) - when :weth - BigDecimal(balance.amount) / BigDecimal(Coinbase::WEI_PER_ETHER) - else - BigDecimal(balance.amount) - end - balances[asset_id] = amount - end - - BalanceMap.new(balances) - end - # Loads the default user. # @return [Coinbase::User] the default user def self.load_default_user diff --git a/lib/coinbase/address.rb b/lib/coinbase/address.rb index a5eff5c..f10697b 100644 --- a/lib/coinbase/address.rb +++ b/lib/coinbase/address.rb @@ -35,45 +35,32 @@ def wallet_id # Returns the Address ID. # @return [String] The Address ID - def address_id + def id @model.address_id end # Returns the balances of the Address. # @return [BalanceMap] The balances of the Address, keyed by asset ID. Ether balances are denominated # in ETH. - def list_balances + def balances response = Coinbase.call_api do - addresses_api.list_address_balances(wallet_id, address_id) + addresses_api.list_address_balances(wallet_id, id) end - Coinbase.to_balance_map(response) + Coinbase::BalanceMap.from_balances(response.data) end # Returns the balance of the provided Asset. # @param asset_id [Symbol] The Asset to retrieve the balance for # @return [BigDecimal] The balance of the Asset - def get_balance(asset_id) - normalized_asset_id = normalize_asset_id(asset_id) - + def balance(asset_id) response = Coinbase.call_api do - addresses_api.get_address_balance(wallet_id, address_id, normalized_asset_id.to_s) + addresses_api.get_address_balance(wallet_id, id, Coinbase::Asset.primary_denomination(asset_id).to_s) end return BigDecimal('0') if response.nil? - amount = BigDecimal(response.amount) - - case asset_id - when :eth - amount / BigDecimal(Coinbase::WEI_PER_ETHER.to_s) - when :gwei - amount / BigDecimal(Coinbase::GWEI_PER_ETHER.to_s) - when :usdc - amount / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC.to_s) - else - amount - end + Coinbase::Balance.from_model_and_asset_id(response, asset_id).amount end # Transfers the given amount of the given Asset to the given address. Only same-Network Transfers are supported. @@ -83,36 +70,32 @@ def get_balance(asset_id) # default address. If a String, interprets it as the address ID. # @return [String] The hash of the Transfer transaction. def transfer(amount, asset_id, destination) - raise ArgumentError, "Unsupported asset: #{asset_id}" unless Coinbase::SUPPORTED_ASSET_IDS[asset_id] + raise ArgumentError, "Unsupported asset: #{asset_id}" unless Coinbase::Asset.supported?(asset_id) if destination.is_a?(Wallet) raise ArgumentError, 'Transfer must be on the same Network' if destination.network_id != network_id - destination = destination.default_address.address_id + destination = destination.default_address.id elsif destination.is_a?(Address) raise ArgumentError, 'Transfer must be on the same Network' if destination.network_id != network_id - destination = destination.address_id + destination = destination.id end - current_balance = get_balance(asset_id) + current_balance = balance(asset_id) if current_balance < amount raise ArgumentError, "Insufficient funds: #{amount} requested, but only #{current_balance} available" end - normalized_amount = normalize_asset_amount(amount, asset_id) - - normalized_asset_id = normalize_asset_id(asset_id) - create_transfer_request = { - amount: normalized_amount.to_i.to_s, + amount: Coinbase::Asset.to_atomic_amount(amount, asset_id).to_i.to_s, network_id: network_id, - asset_id: normalized_asset_id.to_s, + asset_id: Coinbase::Asset.primary_denomination(asset_id).to_s, destination: destination } transfer_model = Coinbase.call_api do - transfers_api.create_transfer(wallet_id, address_id, create_transfer_request) + transfers_api.create_transfer(wallet_id, id, create_transfer_request) end transfer = Coinbase::Transfer.new(transfer_model) @@ -127,7 +110,7 @@ def transfer(amount, asset_id, destination) } transfer_model = Coinbase.call_api do - transfers_api.broadcast_transfer(wallet_id, address_id, transfer.transfer_id, broadcast_transfer_request) + transfers_api.broadcast_transfer(wallet_id, id, transfer.id, broadcast_transfer_request) end Coinbase::Transfer.new(transfer_model) @@ -136,7 +119,7 @@ def transfer(amount, asset_id, destination) # Returns a String representation of the Address. # @return [String] a String representation of the Address def to_s - "Coinbase::Address{address_id: '#{address_id}', network_id: '#{network_id}', wallet_id: '#{wallet_id}'}" + "Coinbase::Address{id: '#{id}', network_id: '#{network_id}', wallet_id: '#{wallet_id}'}" end # Same as to_s. @@ -148,11 +131,11 @@ def inspect # Requests funds for the address from the faucet and returns the faucet transaction. # This is only supported on testnet networks. # @return [Coinbase::FaucetTransaction] The successful faucet transaction - # @raise [Coinbase::FaucetLimitReached] If the faucet limit has been reached for the address or user. + # @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 Coinbase.call_api do - Coinbase::FaucetTransaction.new(addresses_api.request_faucet_funds(wallet_id, address_id)) + Coinbase::FaucetTransaction.new(addresses_api.request_faucet_funds(wallet_id, id)) end end @@ -162,61 +145,30 @@ def export @key.private_hex end - # Lists the IDs of all Transfers associated with the given Wallet and Address. - # @return [Array] The IDs of all Transfers belonging to the Wallet and Address - def list_transfer_ids - transfer_ids = [] + # Returns all of the transfers associated with the address. + # @return [Array] The transfers associated with the address + def transfers + transfers = [] page = nil loop do + puts "fetch transfers page: #{page}" response = Coinbase.call_api do - transfers_api.list_transfers(wallet_id, address_id, { limit: 100, page: page }) + transfers_api.list_transfers(wallet_id, id, { limit: 100, page: page }) end - transfer_ids.concat(response.data.map(&:transfer_id)) if response.data + transfers.concat(response.data.map { |transfer| Coinbase::Transfer.new(transfer) }) if response.data break unless response.has_more page = response.next_page end - transfer_ids + transfers end private - # Normalizes the amount of the Asset to send to the atomic unit. - # @param amount [Integer, Float, BigDecimal] The amount to normalize - # @param asset_id [Symbol] The ID of the Asset being transferred - # @return [BigDecimal] The normalized amount in atomic units - def normalize_asset_amount(amount, asset_id) - big_amount = BigDecimal(amount.to_s) - - case asset_id - when :eth - big_amount * Coinbase::WEI_PER_ETHER - when :gwei - big_amount * Coinbase::WEI_PER_GWEI - when :usdc - big_amount * Coinbase::ATOMIC_UNITS_PER_USDC - when :weth - big_amount * Coinbase::WEI_PER_ETHER - else - big_amount - end - end - - # Normalizes the asset ID to use during requests. - # @param asset_id [Symbol] The asset ID to normalize - # @return [Symbol] The normalized asset ID - def normalize_asset_id(asset_id) - if %i[wei gwei].include?(asset_id) - :eth - else - asset_id - end - end - def addresses_api @addresses_api ||= Coinbase::Client::AddressesApi.new(Coinbase.configuration.api_client) end diff --git a/lib/coinbase/asset.rb b/lib/coinbase/asset.rb index d310338..f164703 100644 --- a/lib/coinbase/asset.rb +++ b/lib/coinbase/asset.rb @@ -3,7 +3,63 @@ module Coinbase # A representation of an Asset. class Asset - attr_reader :network_id, :asset_id, :display_name, :address_id + # Retuns whether the provided asset ID is supported. + # @param asset_id [Symbol] The Asset ID + # @return [Boolean] Whether the Asset ID is supported + def self.supported?(asset_id) + !!Coinbase::SUPPORTED_ASSET_IDS[asset_id] + end + + # Converts the amount of the Asset to the atomic units of the primary denomination of the Asset. + # @param amount [Integer, Float, BigDecimal] The amount to normalize + # @param asset_id [Symbol] The ID of the Asset being transferred + # @return [BigDecimal] The normalized amount in atomic units + def self.to_atomic_amount(amount, asset_id) + case asset_id + when :eth + amount * BigDecimal(Coinbase::WEI_PER_ETHER.to_s) + when :gwei + amount * BigDecimal(Coinbase::WEI_PER_GWEI.to_s) + when :usdc + amount * BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC.to_s) + when :weth + amount * BigDecimal(Coinbase::WEI_PER_ETHER) + else + amount + end + end + + # Converts an amount from the atomic value of the primary denomination of the provided Asset ID + # to whole units of the specified asset ID. + # @param atomic_amount [BigDecimal] The amount in atomic units + # @param asset_id [Symbol] The Asset ID + # @return [BigDecimal] The amount in whole units of the specified asset ID + def self.from_atomic_amount(atomic_amount, asset_id) + case asset_id + when :eth + atomic_amount / BigDecimal(Coinbase::WEI_PER_ETHER.to_s) + when :gwei + atomic_amount / BigDecimal(Coinbase::WEI_PER_GWEI.to_s) + when :usdc + atomic_amount / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC.to_s) + when :weth + atomic_amount / BigDecimal(Coinbase::WEI_PER_ETHER) + else + atomic_amount + end + end + + # Returns the primary denomination for the provided Asset ID. + # For assets with multiple denominations, e.g. eth can also be denominated in wei and gwei, + # this method will return the primary denomination. + # e.g. eth. + # @param asset_id [Symbol] The Asset ID + # @return [Symbol] The primary denomination for the Asset ID + def self.primary_denomination(asset_id) + return :eth if %i[wei gwei].include?(asset_id) + + asset_id + end # Returns a new Asset object. Do not use this method. Instead, use the Asset constants defined in # the Coinbase module. @@ -17,5 +73,20 @@ def initialize(network_id:, asset_id:, display_name:, address_id: nil) @display_name = display_name @address_id = address_id end + + attr_reader :network_id, :asset_id, :display_name, :address_id + + # Returns a string representation of the Asset. + # @return [String] a string representation of the Asset + def to_s + "Coinbase::Asset{network_id: '#{network_id}', asset_id: '#{asset_id}', display_name: '#{display_name}'" + + (address_id.nil? ? '}' : ", address_id: '#{address_id}'}") + end + + # Same as to_s. + # @return [String] a string representation of the Balance + def inspect + to_s + end end end diff --git a/lib/coinbase/balance.rb b/lib/coinbase/balance.rb new file mode 100644 index 0000000..cad1bcb --- /dev/null +++ b/lib/coinbase/balance.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Coinbase + # A representation of an Balance. + class Balance + # Converts a Coinbase::Client::Balance model to a Coinbase::Balance + # @param balance_model [Coinbase::Client::Balance] The balance fetched from the API. + # @return [Balance] The converted Balance object. + def self.from_model(balance_model) + asset_id = Coinbase.to_sym(balance_model.asset.asset_id.downcase) + + from_model_and_asset_id(balance_model, asset_id) + end + + # Converts a Coinbase::Client::Balance model and asset ID to a Coinbase::Balance + # This can be used to specify a non-primary denomination that we want the balance + # to be converted to. + # @param balance_model [Coinbase::Client::Balance] The balance fetched from the API. + # @param asset_id [Symbol] The Asset ID of the denomination we want returned. + # @return [Balance] The converted Balance object. + def self.from_model_and_asset_id(balance_model, asset_id) + new( + amount: Coinbase::Asset.from_atomic_amount(BigDecimal(balance_model.amount), asset_id), + asset_id: asset_id + ) + end + + # Returns a new Asset object. Do not use this method. Instead, use the Asset constants defined in + # the Coinbase module. + # @param network_id [Symbol] The ID of the Network to which the Asset belongs + # @param asset_id [Symbol] The Asset ID + # @param display_name [String] The Asset's display name + # @param address_id [String] (Optional) The Asset's address ID, if one exists + def initialize(amount:, asset_id:) + @amount = amount + @asset_id = asset_id + end + + attr_reader :amount, :asset_id + + # Returns a string representation of the Balance. + # @return [String] a string representation of the Balance + def to_s + "Coinbase::Balance{amount: '#{amount.to_i}', asset_id: '#{asset_id}'}" + end + + # Same as to_s. + # @return [String] a string representation of the Balance + def inspect + to_s + end + end +end diff --git a/lib/coinbase/balance_map.rb b/lib/coinbase/balance_map.rb index 5defce8..4b26b42 100644 --- a/lib/coinbase/balance_map.rb +++ b/lib/coinbase/balance_map.rb @@ -5,15 +5,27 @@ module Coinbase # A convenience class for printing out Asset balances in a human-readable format. class BalanceMap < Hash - # Returns a new BalanceMap object. - # @param hash [Map] The hash to initialize with - def initialize(hash = {}) - super() - hash.each do |key, value| - self[key] = value + # Converts a list of Coinbase::Client::Balance models to a Coinbase::BalanceMap. + # @param balances [Array] The list of balances fetched from the API. + # @return [BalanceMap] The converted BalanceMap object. + def self.from_balances(balances) + BalanceMap.new.tap do |balance_map| + balances.each do |balance_model| + balance = Coinbase::Balance.from_model(balance_model) + + balance_map.add(balance) + end end end + # Adds a balance to the map. + # @param balance [Coinbase::Balance] The balance to add to the map. + def add(balance) + raise ArgumentError, 'balance must be a Coinbase::Balance' unless balance.is_a?(Coinbase::Balance) + + self[balance.asset_id] = balance.amount + end + # Returns a string representation of the balance map. # @return [String] The string representation of the balance def to_s diff --git a/lib/coinbase/transfer.rb b/lib/coinbase/transfer.rb index 4580473..cdfef0c 100644 --- a/lib/coinbase/transfer.rb +++ b/lib/coinbase/transfer.rb @@ -37,7 +37,7 @@ def initialize(model) # Returns the Transfer ID. # @return [String] The Transfer ID - def transfer_id + def id @model.transfer_id end @@ -179,7 +179,7 @@ def wait!(interval_seconds = 0.2, timeout_seconds = 10) # Returns a String representation of the Transfer. # @return [String] a String representation of the Transfer def to_s - "Coinbase::Transfer{transfer_id: '#{transfer_id}', network_id: '#{network_id}', " \ + "Coinbase::Transfer{transfer_id: '#{id}', network_id: '#{network_id}', " \ "from_address_id: '#{from_address_id}', destination_address_id: '#{destination_address_id}', " \ "asset_id: '#{asset_id}', amount: '#{amount}', transaction_hash: '#{transaction_hash}', " \ "transaction_link: '#{transaction_link}', status: '#{status}'}" diff --git a/lib/coinbase/user.rb b/lib/coinbase/user.rb index e909520..5e8f290 100644 --- a/lib/coinbase/user.rb +++ b/lib/coinbase/user.rb @@ -15,7 +15,7 @@ def initialize(model) # Returns the User ID. # @return [String] the User ID - def user_id + def id @model.id end @@ -41,15 +41,7 @@ def create_wallet # @param data [Coinbase::Wallet::Data] the Wallet data to import # @return [Coinbase::Wallet] the imported Wallet def import_wallet(data) - model = Coinbase.call_api do - wallets_api.get_wallet(data.wallet_id) - end - - address_count = Coinbase.call_api do - addresses_api.list_addresses(model.id).total_count - end - - Wallet.new(model, seed: data.seed, address_count: address_count) + Wallet.import(data) end # Lists the IDs of the Wallets belonging to the User. @@ -141,7 +133,7 @@ def load_wallets # Returns a string representation of the User. # @return [String] a string representation of the User def to_s - "Coinbase::User{user_id: '#{user_id}'}" + "Coinbase::User{user_id: '#{id}'}" end # Same as to_s. diff --git a/lib/coinbase/wallet.rb b/lib/coinbase/wallet.rb index f44ae34..c7606ea 100644 --- a/lib/coinbase/wallet.rb +++ b/lib/coinbase/wallet.rb @@ -12,6 +12,37 @@ module Coinbase # list their balances, and transfer Assets to other Addresses. Wallets should be created through User#create_wallet or # User#import_wallet. class Wallet + class << self + # Imports a Wallet from previously exported wallet data. + # @param data [Coinbase::Wallet::Data] the Wallet data to import + # @return [Coinbase::Wallet] the imported Wallet + def import(data) + raise ArgumentError, 'data must be a Coinbase::Wallet::Data object' unless data.is_a?(Data) + + model = Coinbase.call_api do + wallets_api.get_wallet(data.wallet_id) + end + + # TODO: Pass these addresses in directly + address_count = Coinbase.call_api do + addresses_api.list_addresses(model.id).total_count + end + + new(model, seed: data.seed, address_count: address_count) + end + + private + + # TODO: Memoize these objects in a thread-safe way at the top-level. + def addresses_api + Coinbase::Client::AddressesApi.new(Coinbase.configuration.api_client) + end + + def wallets_api + Coinbase::Client::WalletsApi.new(Coinbase.configuration.api_client) + end + end + # Returns a new Wallet object. Do not use this method directly. Instead, use User#create_wallet or # User#import_wallet. # @param model [Coinbase::Client::Wallet] The underlying Wallet object @@ -43,9 +74,11 @@ def initialize(model, seed: nil, address_count: 0) end end + attr_reader :addresses + # Returns the Wallet ID. # @return [String] The Wallet ID - def wallet_id + def id @model.id end @@ -69,7 +102,7 @@ def create_address } } address_model = Coinbase.call_api do - addresses_api.create_address(wallet_id, opts) + addresses_api.create_address(id, opts) end cache_address(address_model, key) @@ -78,60 +111,37 @@ def create_address # Returns the default address of the Wallet. # @return [Address] The default address def default_address - @addresses.find { |address| address.address_id == @model.default_address.address_id } + address(@model.default_address.address_id) end # Returns the Address with the given ID. # @param address_id [String] The ID of the Address to retrieve # @return [Address] The Address - def get_address(address_id) - @addresses.find { |address| address.address_id == address_id } - end - - # Returns the list of Addresses in the Wallet. - # @return [Array
] The list of Addresses - def list_addresses - @addresses + def address(address_id) + @addresses.find { |address| address.id == address_id } end # Returns the list of balances of this Wallet. Balances are aggregated across all Addresses in the Wallet. # @return [BalanceMap] The list of balances. The key is the Asset ID, and the value is the balance. - def list_balances + def balances response = Coinbase.call_api do - wallets_api.list_wallet_balances(wallet_id) + wallets_api.list_wallet_balances(id) end - Coinbase.to_balance_map(response) + Coinbase::BalanceMap.from_balances(response.data) end # Returns the balance of the provided Asset. Balances are aggregated across all Addresses in the Wallet. # @param asset_id [Symbol] The ID of the Asset to retrieve the balance for # @return [BigDecimal] The balance of the Asset - def get_balance(asset_id) - normalized_asset_id = if %i[wei gwei].include?(asset_id) - :eth - else - asset_id - end - + def balance(asset_id) response = Coinbase.call_api do - wallets_api.get_wallet_balance(wallet_id, normalized_asset_id.to_s) + wallets_api.get_wallet_balance(id, Coinbase::Asset.primary_denomination(asset_id).to_s) end return BigDecimal('0') if response.nil? - amount = BigDecimal(response.amount) - - case asset_id - when :eth - amount / BigDecimal(Coinbase::WEI_PER_ETHER.to_s) - when :gwei - amount / BigDecimal(Coinbase::GWEI_PER_ETHER.to_s) - when :usdc - amount / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC.to_s) - else - amount - end + Coinbase::Balance.from_model_and_asset_id(response, asset_id).amount end # Transfers the given amount of the given Asset to the given address. Only same-Network Transfers are supported. @@ -145,11 +155,11 @@ def transfer(amount, asset_id, destination) if destination.is_a?(Wallet) raise ArgumentError, 'Transfer must be on the same Network' if destination.network_id != @network_id - destination = destination.default_address.address_id + destination = destination.default_address.id elsif destination.is_a?(Address) raise ArgumentError, 'Transfer must be on the same Network' if destination.network_id != @network_id - destination = destination.address_id + destination = destination.id end default_address.transfer(amount, asset_id, destination) @@ -158,14 +168,14 @@ def transfer(amount, asset_id, destination) # Exports the Wallet's data to a Data object. # @return [Data] The Wallet data def export - Data.new(wallet_id: wallet_id, seed: @master.seed_hex) + Data.new(wallet_id: id, seed: @master.seed_hex) end # Returns a String representation of the Wallet. # @return [String] a String representation of the Wallet def to_s - "Coinbase::Wallet{wallet_id: '#{wallet_id}', network_id: '#{network_id}', " \ - "default_address: '#{default_address.address_id}'}" + "Coinbase::Wallet{wallet_id: '#{id}', network_id: '#{network_id}', " \ + "default_address: '#{default_address.id}'}" end # Same as to_s. @@ -209,7 +219,7 @@ def derive_address address_id = key.address.to_s address_model = Coinbase.call_api do - addresses_api.get_address(wallet_id, address_id) + addresses_api.get_address(id, address_id) end cache_address(address_model, key) @@ -240,7 +250,7 @@ def cache_address(address_model, key) def create_attestation(key) public_key = key.public_key.compressed.unpack1('H*') payload = { - wallet_id: wallet_id, + wallet_id: id, public_key: public_key }.to_json hashed_payload = Digest::SHA256.digest(payload) @@ -261,7 +271,7 @@ def create_attestation(key) # Updates the Wallet model with the latest data. def update_model @model = Coinbase.call_api do - wallets_api.get_wallet(wallet_id) + wallets_api.get_wallet(id) end end diff --git a/spec/e2e/production.rb b/spec/e2e/production.rb index 0e7fee3..9a7af33 100644 --- a/spec/e2e/production.rb +++ b/spec/e2e/production.rb @@ -18,12 +18,12 @@ puts 'Fetching default user...' u = Coinbase.default_user expect(u).not_to be_nil - puts "Fetched default user with ID: #{u.user_id}" + puts "Fetched default user with ID: #{u.id}" puts 'Creating new wallet...' w1 = u.create_wallet expect(w1).not_to be_nil - puts "Created new wallet with ID: #{w1.wallet_id}, default address: #{w1.default_address}" + puts "Created new wallet with ID: #{w1.id}, default address: #{w1.default_address}" puts 'Importing wallet with balance...' data_string = ENV['WALLET_DATA'] @@ -31,15 +31,15 @@ data = Coinbase::Wallet::Data.from_hash(data_hash) w2 = u.import_wallet(data) expect(w2).not_to be_nil - puts "Imported wallet with ID: #{w2.wallet_id}, default address: #{w2.default_address}" + puts "Imported wallet with ID: #{w2.id}, default address: #{w2.default_address}" - puts 'Listing addresses...' - addresses = w2.list_addresses + puts 'Listing wallet addresses...' + addresses = w2.addresses expect(addresses.length).to be > 1 puts "Listed addresses: #{addresses.map(&:to_s).join(', ')}" - puts 'Fetching balances...' - balances = w2.list_balances + puts 'Fetching wallet balances...' + balances = w2.balances expect(balances.length).to be >= 1 puts "Fetched balances: #{balances}" @@ -51,8 +51,8 @@ puts "Transferred 1 Gwei from #{a1} to #{a2}" puts 'Fetching updated balances...' - first_balance = a1.list_balances - second_balance = a2.list_balances + first_balance = a1.balances + second_balance = a2.balances expect(first_balance[:eth]).to be > BigDecimal('0') expect(second_balance[:eth]).to be > BigDecimal('0') puts "First address balances: #{first_balance}" diff --git a/spec/unit/coinbase/address_spec.rb b/spec/unit/coinbase/address_spec.rb index e3ee5d9..ceb1905 100644 --- a/spec/unit/coinbase/address_spec.rb +++ b/spec/unit/coinbase/address_spec.rb @@ -40,9 +40,9 @@ end end - describe '#address_id' do + describe '#id' do it 'returns the address ID' do - expect(address.address_id).to eq(address_id) + expect(address.id).to eq(address_id) end end @@ -52,7 +52,7 @@ end end - describe '#list_balances' do + describe '#balances' do let(:response) do Coinbase::Client::AddressBalanceList.new( 'data' => [ @@ -75,6 +75,16 @@ 'decimals': 6 }) } + ), + Coinbase::Client::Balance.new( + { + 'amount' => '3000000000000000000', + 'asset' => Coinbase::Client::Asset.new({ + 'network_id': 'base-sepolia', + 'asset_id': 'weth', + 'decimals': 6 + }) + } ) ] ) @@ -85,11 +95,16 @@ .to receive(:list_address_balances) .with(wallet_id, address_id) .and_return(response) - expect(address.list_balances).to eq(eth: BigDecimal('1'), usdc: BigDecimal('5000')) + + expect(address.balances).to eq( + eth: BigDecimal('1'), + usdc: BigDecimal('5000'), + weth: BigDecimal('3') + ) end end - describe '#get_balance' do + describe '#balance' do let(:response) do Coinbase::Client::Balance.new( { @@ -108,7 +123,7 @@ .to receive(:get_address_balance) .with(wallet_id, address_id, 'eth') .and_return(response) - expect(address.get_balance(:eth)).to eq BigDecimal('1') + expect(address.balance(:eth)).to eq BigDecimal('1') end it 'returns the correct Gwei balance' do @@ -116,7 +131,7 @@ .to receive(:get_address_balance) .with(wallet_id, address_id, 'eth') .and_return(response) - expect(address.get_balance(:gwei)).to eq BigDecimal('1_000_000_000') + expect(address.balance(:gwei)).to eq BigDecimal('1_000_000_000') end it 'returns the correct Wei balance' do @@ -124,7 +139,7 @@ .to receive(:get_address_balance) .with(wallet_id, address_id, 'eth') .and_return(response) - expect(address.get_balance(:wei)).to eq BigDecimal('1_000_000_000_000_000_000') + expect(address.balance(:wei)).to eq BigDecimal('1_000_000_000_000_000_000') end it 'returns 0 for an unsupported asset' do @@ -132,7 +147,7 @@ .to receive(:get_address_balance) .with(wallet_id, address_id, 'uni') .and_return(nil) - expect(address.get_balance(:uni)).to eq BigDecimal('0') + expect(address.balance(:uni)).to eq BigDecimal('0') end end @@ -172,7 +187,7 @@ end let(:transaction) { double('Transaction', sign: transaction_hash, hex: raw_signed_transaction) } let(:transfer) do - double('Transfer', transaction: transaction, transfer_id: transfer_id) + double('Transfer', transaction: transaction, id: transfer_id) end before do @@ -186,7 +201,7 @@ let(:amount) { 500_000_000_000_000_000 } let(:destination) { described_class.new(model, to_key) } let(:create_transfer_request) do - { amount: amount.to_s, network_id: network_id, asset_id: 'eth', destination: destination.address_id } + { amount: amount.to_s, network_id: network_id, asset_id: 'eth', destination: destination.id } end it 'creates a Transfer' do @@ -210,8 +225,12 @@ let(:usdc_atomic_amount) { 5_000_000 } let(:destination) { described_class.new(model, to_key) } let(:create_transfer_request) do - { amount: usdc_atomic_amount.to_s, network_id: network_id, asset_id: 'usdc', - destination: destination.address_id } + { + amount: usdc_atomic_amount.to_s, + network_id: network_id, + asset_id: 'usdc', + destination: destination.id + } end it 'creates a Transfer' do @@ -225,6 +244,7 @@ expect(transfers_api) .to receive(:broadcast_transfer) .with(wallet_id, address_id, transfer_id, broadcast_transfer_request) + expect(address.transfer(usdc_amount, asset_id, destination)).to eq(transfer) end end @@ -391,16 +411,67 @@ end end - describe '#list_transfer_ids' do - let(:transfer_ids) { [SecureRandom.uuid, SecureRandom.uuid] } + describe '#transfers' do + let(:page_size) { 6 } + let(:transfer_ids) do + Array.new(page_size) { SecureRandom.uuid } + end let(:data) do transfer_ids.map { |id| Coinbase::Client::Transfer.new({ 'transfer_id': id, 'network_id': 'base-sepolia' }) } end let(:transfers_list) { Coinbase::Client::TransferList.new({ 'data' => data }) } - let(:opts) { { limit: 100, page: nil } } - it 'lists the transfer IDs' do - allow(transfers_api).to receive(:list_transfers).with(wallet_id, address_id, opts).and_return(transfers_list) - expect(address.list_transfer_ids).to eq(transfer_ids) + let(:expected_transfers) do + data.map { |transfer_model| Coinbase::Transfer.new(transfer_model) } + end + + before do + data.each_with_index do |transfer_model, i| + allow(Coinbase::Transfer).to receive(:new).with(transfer_model).and_return(expected_transfers[i]) + end + end + + it 'lists the transfers' do + expect(transfers_api) + .to receive(:list_transfers) + .with(wallet_id, address_id, { limit: 100, page: nil }) + .and_return(transfers_list) + + expect(address.transfers).to eq(expected_transfers) + end + + context 'with multiple pages' do + let(:page_size) { 150 } + let(:next_page) { 'page_token_2' } + let(:transfers_list_page1) do + Coinbase::Client::TransferList.new({ 'data' => data.take(100), 'has_more' => true, 'next_page' => next_page }) + end + let(:transfers_list_page2) do + Coinbase::Client::TransferList.new({ 'data' => data.drop(100), 'has_more' => false, 'next_page' => nil }) + end + + it 'lists all of the transfers' do + expect(transfers_api) + .to receive(:list_transfers) + .with(wallet_id, address_id, { limit: 100, page: nil }) + .and_return(transfers_list_page1) + + expect(transfers_api) + .to receive(:list_transfers) + .with(wallet_id, address_id, { limit: 100, page: next_page }) + .and_return(transfers_list_page2) + + expect(address.transfers).to eq(expected_transfers) + end + end + end + + describe '#inspect' do + it 'includes address details' do + expect(address.inspect).to include(address_id, Coinbase.to_sym(network_id).to_s, wallet_id) + end + + it 'returns the same value as to_s' do + expect(address.inspect).to eq(address.to_s) end end end diff --git a/spec/unit/coinbase/asset_spec.rb b/spec/unit/coinbase/asset_spec.rb new file mode 100644 index 0000000..916dca6 --- /dev/null +++ b/spec/unit/coinbase/asset_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +describe Coinbase::Asset do + describe '.supported?' do + %i[eth gwei wei usdc weth].each do |asset_id| + context "when the asset_id is #{asset_id}" do + it 'returns true' do + expect(described_class.supported?(asset_id)).to be true + end + end + end + + context 'when the asset_id is not supported' do + it 'returns false' do + expect(described_class.supported?(:unsupported)).to be false + end + end + end + + describe '.to_atomic_amount' do + let(:amount) { 123.0 } + + context 'when the asset_id is :eth' do + it 'returns the amount in atomic units' do + expect(described_class.to_atomic_amount(amount, :eth)).to eq(BigDecimal('123000000000000000000')) + end + end + + context 'when the asset_id is :gwei' do + it 'returns the amount in atomic units' do + expect(described_class.to_atomic_amount(amount, :gwei)).to eq(BigDecimal('123000000000')) + end + end + + context 'when the asset_id is :usdc' do + it 'returns the amount in atomic units' do + expect(described_class.to_atomic_amount(amount, :usdc)).to eq(BigDecimal('123000000')) + end + end + + context 'when the asset_id is :weth' do + it 'returns the amount in atomic units' do + expect(described_class.to_atomic_amount(amount, :weth)).to eq(BigDecimal('123000000000000000000')) + end + end + + context 'when the asset_id is :wei' do + it 'returns the amount' do + expect(described_class.to_atomic_amount(amount, :wei)).to eq(BigDecimal('123.0')) + end + end + + context 'when the asset_id is not explicitly handled' do + it 'returns the amount' do + expect(described_class.to_atomic_amount(amount, :other)).to eq(BigDecimal('123.0')) + end + end + end + + describe '.from_atomic_amount' do + let(:atomic_amount) { BigDecimal('123000000000000000000') } + + context 'when the asset_id is :eth' do + it 'returns the amount in whole units' do + expect(described_class.from_atomic_amount(atomic_amount, :eth)).to eq(BigDecimal('123.0')) + end + end + + context 'when the asset_id is :gwei' do + it 'returns the amount in gwei' do + expect(described_class.from_atomic_amount(atomic_amount, :gwei)).to eq(BigDecimal('123000000000')) + end + end + + context 'when the asset_id is :usdc' do + it 'returns the amount in whole units' do + expect(described_class.from_atomic_amount(atomic_amount, :usdc)).to eq(BigDecimal('123000000000000')) + end + end + + context 'when the asset_id is :weth' do + it 'returns the amount in whole units' do + expect(described_class.from_atomic_amount(atomic_amount, :weth)).to eq(BigDecimal('123.0')) + end + end + + context 'when the asset_id is :wei' do + it 'returns the amount' do + expect(described_class.from_atomic_amount(atomic_amount, :wei)).to eq(BigDecimal('123000000000000000000')) + end + end + end + + describe '.primary_denomination' do + %i[wei gwei].each do |asset_id| + context "when the asset_id is #{asset_id}" do + it 'returns :eth' do + expect(described_class.primary_denomination(asset_id)).to eq(:eth) + end + end + end + + context 'when the asset_id is not wei or gwei' do + it 'returns the asset_id' do + expect(described_class.primary_denomination(:other)).to eq(:other) + end + end + end + + describe '#initialize' do + let(:network_id) { :base_sepolia } + let(:asset_id) { :eth } + let(:display_name) { 'Ether' } + let(:address_id) { '0x036CbD53842' } + + subject(:asset) do + described_class.new( + network_id: network_id, + asset_id: asset_id, + display_name: display_name, + address_id: address_id + ) + end + + it 'sets the network_id' do + expect(asset.network_id).to eq(network_id) + end + + it 'sets the asset_id' do + expect(asset.asset_id).to eq(asset_id) + end + + it 'sets the display_name' do + expect(asset.display_name).to eq(display_name) + end + + it 'sets the address_id' do + expect(asset.address_id).to eq(address_id) + end + end + + describe '#inspect' do + let(:network_id) { :base_sepolia } + let(:asset_id) { :eth } + let(:display_name) { 'Ether' } + + subject(:asset) do + described_class.new( + network_id: network_id, + asset_id: asset_id, + display_name: display_name + ) + end + + it 'includes asset details' do + expect(asset.inspect).to include( + Coinbase.to_sym(network_id).to_s, + asset_id.to_s, + display_name + ) + end + + it 'returns the same value as to_s' do + expect(asset.inspect).to eq(asset.to_s) + end + + context 'when the asset contains an address_id' do + let(:address_id) { '0x036CbD53842' } + + subject(:asset) do + described_class.new( + network_id: network_id, + asset_id: asset_id, + display_name: display_name, + address_id: address_id + ) + end + + it 'includes the transaction hash' do + expect(asset.inspect).to include(address_id) + end + end + end +end diff --git a/spec/unit/coinbase/balance_map_spec.rb b/spec/unit/coinbase/balance_map_spec.rb new file mode 100644 index 0000000..eed135d --- /dev/null +++ b/spec/unit/coinbase/balance_map_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe Coinbase::BalanceMap do + describe '.from_balances' do + let(:eth_amount) { BigDecimal('123.0') } + let(:eth_asset) { instance_double('Coinbase::Client::Asset', asset_id: 'ETH') } + let(:eth_balance_model) { instance_double('Coinbase::Client::Balance', asset: eth_asset, amount: eth_amount) } + + let(:usdc_amount) { BigDecimal('456.0') } + let(:usdc_asset) { instance_double('Coinbase::Client::Asset', asset_id: 'USDC') } + let(:usdc_balance_model) { instance_double('Coinbase::Client::Balance', asset: usdc_asset, amount: usdc_amount) } + + let(:weth_amount) { BigDecimal('789.0') } + let(:weth_asset) { instance_double('Coinbase::Client::Asset', asset_id: 'WETH') } + let(:weth_balance_model) { instance_double('Coinbase::Client::Balance', asset: weth_asset, amount: weth_amount) } + + let(:balances) { [eth_balance_model, usdc_balance_model, weth_balance_model] } + + subject { described_class.from_balances(balances) } + + it 'returns a new BalanceMap object with the correct balances' do + expect(subject[:eth]).to eq(eth_amount / BigDecimal(Coinbase::WEI_PER_ETHER)) + expect(subject[:usdc]).to eq(usdc_amount / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC)) + expect(subject[:weth]).to eq(weth_amount / BigDecimal(Coinbase::WEI_PER_ETHER)) + end + end + + describe '#add' do + let(:amount) { BigDecimal('123.0') } + let(:asset_id) { :eth } + let(:balance) { Coinbase::Balance.new(amount: amount, asset_id: asset_id) } + + subject { described_class.new } + + it 'sets the amount' do + subject.add(balance) + + expect(subject[asset_id]).to eq(amount) + end + + context 'when the balance is not a Coinbase::Balance' do + let(:balance) { instance_double('Coinbase::Balance') } + + it 'raises an ArgumentError' do + expect { subject.add(balance) }.to raise_error(ArgumentError) + end + end + end + + describe '#to_s' do + let(:amount) { BigDecimal('123.0') } + let(:asset_id) { :eth } + let(:balance) { Coinbase::Balance.new(amount: amount, asset_id: asset_id) } + + let(:expected_result) { { eth: '123' }.to_s } + + subject { described_class.new } + + before { subject.add(balance) } + + it 'returns a string representation of asset_id to floating-point number' do + expect(subject.to_s).to eq({ eth: '123' }.to_s) + end + end +end diff --git a/spec/unit/coinbase/balance_spec.rb b/spec/unit/coinbase/balance_spec.rb new file mode 100644 index 0000000..cba7047 --- /dev/null +++ b/spec/unit/coinbase/balance_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +describe Coinbase::Balance do + describe '.from_model' do + let(:amount) { BigDecimal('123.0') } + let(:balance_model) { instance_double('Coinbase::Client::Balance', asset: asset, amount: amount) } + + subject(:balance) { described_class.from_model(balance_model) } + + context 'when the asset is :eth' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'ETH') } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::WEI_PER_ETHER)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(:eth) + end + end + + context 'when the asset is :usdc' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'USDC') } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(:usdc) + end + end + + context 'when the asset is :weth' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'WETH') } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::WEI_PER_ETHER)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(:weth) + end + end + + context 'when the asset is another asset type' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'OTHER') } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(:other) + end + end + end + + describe '.from_model_and_asset_id' do + let(:amount) { BigDecimal('123.0') } + let(:balance_model) { instance_double('Coinbase::Client::Balance', asset: asset, amount: amount) } + + subject(:balance) { described_class.from_model_and_asset_id(balance_model, asset_id) } + + context 'when the balance model asset is :eth' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'ETH') } + + context 'and the specified asset_id is :eth' do + let(:asset_id) { :eth } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::WEI_PER_ETHER)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + + context 'and the specified asset_id is :gwei' do + let(:asset_id) { :gwei } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::GWEI_PER_ETHER)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + + context 'and the specified asset_id is :wei' do + let(:asset_id) { :wei } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + end + + context 'when the asset is :usdc' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'USDC') } + let(:asset_id) { :usdc } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::ATOMIC_UNITS_PER_USDC)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + + context 'when the asset is :weth' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'WETH') } + let(:asset_id) { :weth } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount / BigDecimal(Coinbase::WEI_PER_ETHER)) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + + context 'when the asset is another asset type' do + let(:asset) { instance_double('Coinbase::Client::Asset', asset_id: 'OTHER') } + let(:asset_id) { :other } + + it 'returns a new Balance object with the correct amount' do + expect(balance.amount).to eq(amount) + end + + it 'returns a new Balance object with the correct asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + end + + describe '#initialize' do + let(:amount) { BigDecimal('123.0') } + let(:asset_id) { :eth } + + subject(:balance) { described_class.new(amount: amount, asset_id: asset_id) } + + it 'sets the amount' do + expect(balance.amount).to eq(amount) + end + + it 'sets the asset_id' do + expect(balance.asset_id).to eq(asset_id) + end + end + + describe '#inspect' do + let(:amount) { BigDecimal('123.0') } + let(:asset_id) { :eth } + + subject(:balance) { described_class.new(amount: amount, asset_id: asset_id) } + + it 'includes balance details' do + expect(balance.inspect).to include('123', 'eth') + end + + it 'returns the same value as to_s' do + expect(balance.inspect).to eq(balance.to_s) + end + end +end diff --git a/spec/unit/coinbase/transfer_spec.rb b/spec/unit/coinbase/transfer_spec.rb index 6123526..01e9f52 100644 --- a/spec/unit/coinbase/transfer_spec.rb +++ b/spec/unit/coinbase/transfer_spec.rb @@ -89,7 +89,7 @@ describe '#transfer_id' do it 'returns the transfer ID' do - expect(transfer.transfer_id).to eq(transfer_id) + expect(transfer.id).to eq(transfer_id) end end @@ -385,4 +385,42 @@ end end end + + describe '#inspect' do + it 'includes transfer details' do + expect(transfer.inspect).to include( + transfer_id, + Coinbase.to_sym(network_id).to_s, + from_address_id, + to_address_id, + eth_amount.to_s, + transfer.asset_id.to_s, + transfer.status.to_s + ) + end + + it 'returns the same value as to_s' do + expect(transfer.inspect).to eq(transfer.to_s) + end + + context 'when the transfer has been broadcast on chain' do + let(:onchain_transaction) { { 'blockHash' => nil } } + + subject(:transfer) do + described_class.new(broadcast_model) + end + + before do + transfer.transaction.sign(from_key) + allow(client) + .to receive(:eth_getTransactionByHash) + .with(transfer.transaction_hash) + .and_return(onchain_transaction) + end + + it 'includes the transaction hash' do + expect(transfer.inspect).to include(transfer.transaction_hash) + end + end + end end diff --git a/spec/unit/coinbase/user_spec.rb b/spec/unit/coinbase/user_spec.rb index 9197ec9..79d52cb 100644 --- a/spec/unit/coinbase/user_spec.rb +++ b/spec/unit/coinbase/user_spec.rb @@ -5,12 +5,12 @@ let(:model) { Coinbase::Client::User.new({ 'id': user_id }) } let(:wallets_api) { instance_double(Coinbase::Client::WalletsApi) } let(:addresses_api) { instance_double(Coinbase::Client::AddressesApi) } - let(:user) { described_class.new(model) } let(:transfers_api) { instance_double(Coinbase::Client::TransfersApi) } + subject(:user) { described_class.new(model) } - describe '#user_id' do + describe '#id' do it 'returns the user ID' do - expect(user.user_id).to eq(user_id) + expect(user.id).to eq(user_id) end end @@ -52,66 +52,25 @@ it 'creates a new wallet' do wallet = user.create_wallet expect(wallet).to be_a(Coinbase::Wallet) - expect(wallet.wallet_id).to eq(wallet_id) + expect(wallet.id).to eq(wallet_id) expect(wallet.network_id).to eq(:base_sepolia) end end describe '#import_wallet' do - let(:client) { double('Jimson::Client') } - let(:wallet_id) { SecureRandom.uuid } - let(:wallet_model) { Coinbase::Client::Wallet.new({ 'id': wallet_id, 'network_id': 'base-sepolia' }) } - let(:wallets_api) { double('Coinbase::Client::WalletsApi') } - let(:network_id) { 'base-sepolia' } - let(:create_wallet_request) { { wallet: { network_id: network_id } } } - let(:opts) { { create_wallet_request: create_wallet_request } } - let(:addresses_api) { double('Coinbase::Client::AddressesApi') } - let(:address_model) do - Coinbase::Client::Address.new({ - 'address_id': '0xdeadbeef', - 'wallet_id': wallet_id, - 'public_key': '0x1234567890', - 'network_id': 'base-sepolia' - }) - end - let(:wallet_model_with_default_address) do - Coinbase::Client::Wallet.new( - { - 'id': wallet_id, - 'network_id': 'base-sepolia', - 'default_address': address_model - } - ) - end - let(:address_list_model) do - Coinbase::Client::AddressList.new({ 'data' => [address_model], 'total_count' => 1 }) - end let(:wallet_export_data) do Coinbase::Wallet::Data.new( - wallet_id: wallet_id, + wallet_id: SecureRandom.uuid, seed: MoneyTree::Master.new.seed_hex ) end + let(:wallet) { instance_double(Coinbase::Wallet) } subject(:imported_wallet) { user.import_wallet(wallet_export_data) } - before do - allow(Coinbase::Client::AddressesApi).to receive(:new).and_return(addresses_api) - allow(Coinbase::Client::WalletsApi).to receive(:new).and_return(wallets_api) - expect(wallets_api).to receive(:get_wallet).with(wallet_id).and_return(wallet_model_with_default_address) - expect(addresses_api).to receive(:list_addresses).with(wallet_id).and_return(address_list_model) - expect(addresses_api).to receive(:get_address).and_return(address_model) - end - it 'imports an exported wallet' do - expect(imported_wallet.wallet_id).to eq(wallet_id) - end - - it 'loads the wallet addresses' do - expect(imported_wallet.list_addresses.length).to eq(address_list_model.total_count) - end + allow(Coinbase::Wallet).to receive(:import).with(wallet_export_data).and_return(wallet) - it 'contains the same seed when re-exported' do - expect(imported_wallet.export.seed).to eq(wallet_export_data.seed) + expect(user.import_wallet(wallet_export_data)).to eq(wallet) end end @@ -150,7 +109,7 @@ let(:initial_seed_data) { JSON.pretty_generate({}) } let(:expected_seed_data) do { - seed_wallet.wallet_id => { + seed_wallet.id => { seed: seed, encrypted: false } @@ -173,7 +132,7 @@ # Verify that the file has new wallet. stored_seed_data = File.read(Coinbase.configuration.backup_file_path) wallets = JSON.parse(stored_seed_data) - data = wallets[seed_wallet.wallet_id] + data = wallets[seed_wallet.id] expect(data).not_to be_empty expect(data['encrypted']).to eq(false) expect(data['iv']).to eq('') @@ -187,7 +146,7 @@ # Verify that the file has new wallet. stored_seed_data = File.read(Coinbase.configuration.backup_file_path) wallets = JSON.parse(stored_seed_data) - data = wallets[seed_wallet.wallet_id] + data = wallets[seed_wallet.id] expect(data).not_to be_empty expect(data['encrypted']).to eq(true) expect(data['iv']).not_to be_empty @@ -201,7 +160,7 @@ saved_wallet = user.save_wallet(seed_wallet) stored_seed_data = File.read(Coinbase.configuration.backup_file_path) wallets = JSON.parse(stored_seed_data) - data = wallets[seed_wallet.wallet_id] + data = wallets[seed_wallet.id] expect(data).not_to be_empty expect(data['encrypted']).to eq(false) expect(saved_wallet).to eq(seed_wallet) @@ -311,8 +270,8 @@ wallets = user.load_wallets wallet = wallets[wallet_id] expect(wallet).not_to be_nil - expect(wallet.wallet_id).to eq(wallet_id) - expect(wallet.default_address.address_id).to eq(address_model.address_id) + expect(wallet.id).to eq(wallet_id) + expect(wallet.default_address.id).to eq(address_model.address_id) end it 'throws an error when the backup file is absent' do @@ -367,4 +326,14 @@ end.to raise_error(ArgumentError, 'Malformed encrypted seed data') end end + + describe '#inspect' do + it 'includes user details' do + expect(user.inspect).to include(user_id) + end + + it 'returns the same value as to_s' do + expect(user.inspect).to eq(user.to_s) + end + end end diff --git a/spec/unit/coinbase/wallet_spec.rb b/spec/unit/coinbase/wallet_spec.rb index 047874c..c319c7f 100644 --- a/spec/unit/coinbase/wallet_spec.rb +++ b/spec/unit/coinbase/wallet_spec.rb @@ -3,13 +3,14 @@ describe Coinbase::Wallet do let(:client) { double('Jimson::Client') } let(:wallet_id) { SecureRandom.uuid } - let(:model) { Coinbase::Client::Wallet.new({ 'id': wallet_id, 'network_id': 'base-sepolia' }) } + let(:network_id) { 'base-sepolia' } + let(:model) { Coinbase::Client::Wallet.new({ 'id': wallet_id, 'network_id': network_id }) } let(:address_model) do Coinbase::Client::Address.new({ 'address_id': '0xdeadbeef', 'wallet_id': wallet_id, 'public_key': '0x1234567890', - 'network_id': 'base-sepolia' + 'network_id': network_id }) end let(:model_with_default_address) do @@ -25,13 +26,50 @@ let(:addresses_api) { double('Coinbase::Client::AddressesApi') } let(:transfers_api) { double('Coinbase::Client::TransfersApi') } + subject(:wallet) { described_class.new(model) } + before do allow(Coinbase::Client::AddressesApi).to receive(:new).and_return(addresses_api) allow(Coinbase::Client::WalletsApi).to receive(:new).and_return(wallets_api) allow(addresses_api).to receive(:create_address).and_return(address_model) allow(addresses_api).to receive(:get_address).and_return(address_model) allow(wallets_api).to receive(:get_wallet).with(wallet_id).and_return(model_with_default_address) - @wallet = described_class.new(model) + end + + describe '.import' do + let(:client) { double('Jimson::Client') } + let(:wallet_id) { SecureRandom.uuid } + let(:wallet_model) { Coinbase::Client::Wallet.new({ 'id': wallet_id, 'network_id': 'base-sepolia' }) } + let(:create_wallet_request) { { wallet: { network_id: network_id } } } + let(:opts) { { create_wallet_request: create_wallet_request } } + let(:address_list_model) do + Coinbase::Client::AddressList.new({ 'data' => [address_model], 'total_count' => 1 }) + end + let(:exported_data) do + Coinbase::Wallet::Data.new( + wallet_id: wallet_id, + seed: MoneyTree::Master.new.seed_hex + ) + end + subject(:imported_wallet) { Coinbase::Wallet.import(exported_data) } + + before do + expect(wallets_api).to receive(:get_wallet).with(wallet_id).and_return(model_with_default_address) + expect(addresses_api).to receive(:list_addresses).with(wallet_id).and_return(address_list_model) + expect(addresses_api).to receive(:get_address).and_return(address_model) + end + + it 'imports an exported wallet' do + expect(imported_wallet.id).to eq(wallet_id) + end + + it 'loads the wallet addresses' do + expect(imported_wallet.addresses.length).to eq(address_list_model.total_count) + end + + it 'contains the same seed when re-exported' do + expect(imported_wallet.export.seed).to eq(exported_data.seed) + end end describe '#initialize' do @@ -44,8 +82,7 @@ attestation_present = opts[:create_address_request][:attestation].is_a?(String) public_key_present && attestation_present end) - @wallet = described_class.new(model) - expect(@wallet).to be_a(Coinbase::Wallet) + expect(wallet).to be_a(Coinbase::Wallet) end end @@ -80,25 +117,27 @@ it 'initializes a new Wallet with the provided address count' do expect(addresses_api).to receive(:get_address).exactly(address_count).times - expect(address_wallet.list_addresses.length).to eq(address_count) + expect(address_wallet.addresses.length).to eq(address_count) end end end describe '#wallet_id' do it 'returns the Wallet ID' do - expect(@wallet.wallet_id).to eq(wallet_id) + expect(wallet.id).to eq(wallet_id) end end describe '#network_id' do it 'returns the Network ID' do - expect(@wallet.network_id).to eq(:base_sepolia) + expect(wallet.network_id).to eq(:base_sepolia) end end describe '#create_address' do it 'creates a new address' do + expect(wallet.addresses.length).to eq(1) + expect(addresses_api) .to receive(:create_address) .with(wallet_id, satisfy do |opts| @@ -108,37 +147,38 @@ end) .and_return(address_model) .exactly(1).times - address = @wallet.create_address + + address = wallet.create_address expect(address).to be_a(Coinbase::Address) - expect(@wallet.list_addresses.length).to eq(2) - expect(address).not_to eq(@wallet.default_address) + expect(wallet.addresses.length).to eq(2) + expect(address).not_to eq(wallet.default_address) end end describe '#default_address' do it 'returns the first address' do - expect(@wallet.default_address).to eq(@wallet.list_addresses.first) + expect(wallet.default_address).to eq(wallet.addresses.first) end end - describe '#get_address' do + describe '#address' do before do allow(addresses_api).to receive(:create_address).and_return(address_model) end it 'returns the correct address' do - default_address = @wallet.default_address - expect(@wallet.get_address(default_address.address_id)).to eq(default_address) + default_address = wallet.default_address + expect(wallet.address(default_address.id)).to eq(default_address) end end - describe '#list_addresses' do + describe '#addresses' do it 'contains one address' do - expect(@wallet.list_addresses.length).to eq(1) + expect(wallet.addresses.length).to eq(1) end end - describe '#list_balances' do + describe '#balances' do let(:response) do Coinbase::Client::AddressBalanceList.new( 'data' => [ @@ -170,11 +210,11 @@ end it 'returns a hash with an ETH and USDC balance' do - expect(@wallet.list_balances).to eq({ eth: BigDecimal(1), usdc: BigDecimal(5) }) + expect(wallet.balances).to eq({ eth: BigDecimal(1), usdc: BigDecimal(5) }) end end - describe '#get_balance' do + describe '#balance' do let(:response) do Coinbase::Client::Balance.new( { @@ -193,15 +233,15 @@ end it 'returns the correct ETH balance' do - expect(@wallet.get_balance(:eth)).to eq(BigDecimal(5)) + expect(wallet.balance(:eth)).to eq(BigDecimal(5)) end it 'returns the correct Gwei balance' do - expect(@wallet.get_balance(:gwei)).to eq(BigDecimal(5 * Coinbase::GWEI_PER_ETHER)) + expect(wallet.balance(:gwei)).to eq(BigDecimal(5 * Coinbase::GWEI_PER_ETHER)) end it 'returns the correct Wei balance' do - expect(@wallet.get_balance(:wei)).to eq(BigDecimal(5 * Coinbase::WEI_PER_ETHER)) + expect(wallet.balance(:wei)).to eq(BigDecimal(5 * Coinbase::WEI_PER_ETHER)) end end @@ -212,27 +252,27 @@ context 'when the destination is a Wallet' do let(:destination) { described_class.new(model) } - let(:to_address_id) { destination.default_address.address_id } + let(:to_address_id) { destination.default_address.id } before do - expect(@wallet.default_address).to receive(:transfer).with(amount, asset_id, to_address_id).and_return(transfer) + expect(wallet.default_address).to receive(:transfer).with(amount, asset_id, to_address_id).and_return(transfer) end it 'creates a transfer to the default address ID' do - expect(@wallet.transfer(amount, asset_id, destination)).to eq(transfer) + expect(wallet.transfer(amount, asset_id, destination)).to eq(transfer) end end context 'when the desination is an Address' do - let(:destination) { @wallet.create_address } - let(:to_address_id) { destination.address_id } + let(:destination) { wallet.create_address } + let(:to_address_id) { destination.id } before do - expect(@wallet.default_address).to receive(:transfer).with(amount, asset_id, to_address_id).and_return(transfer) + expect(wallet.default_address).to receive(:transfer).with(amount, asset_id, to_address_id).and_return(transfer) end it 'creates a transfer to the address ID' do - expect(@wallet.transfer(amount, asset_id, destination)).to eq(transfer) + expect(wallet.transfer(amount, asset_id, destination)).to eq(transfer) end end @@ -240,11 +280,11 @@ let(:destination) { '0x1234567890' } before do - expect(@wallet.default_address).to receive(:transfer).with(amount, asset_id, destination).and_return(transfer) + expect(wallet.default_address).to receive(:transfer).with(amount, asset_id, destination).and_return(transfer) end it 'creates a transfer to the address ID' do - expect(@wallet.transfer(amount, asset_id, destination)).to eq(transfer) + expect(wallet.transfer(amount, asset_id, destination)).to eq(transfer) end end end @@ -256,20 +296,30 @@ described_class.new(model, seed: seed, address_count: address_count) end - it 'exports the Wallet data' do + it 'exports the wallet data' do wallet_data = seed_wallet.export expect(wallet_data).to be_a(Coinbase::Wallet::Data) - expect(wallet_data.wallet_id).to eq(seed_wallet.wallet_id) + expect(wallet_data.wallet_id).to eq(seed_wallet.id) expect(wallet_data.seed).to eq(seed) end it 'allows for re-creation of a Wallet' do wallet_data = seed_wallet.export new_wallet = described_class.new(model, seed: wallet_data.seed, address_count: address_count) - expect(new_wallet.list_addresses.length).to eq(address_count) - new_wallet.list_addresses.each_with_index do |address, i| - expect(address.address_id).to eq(seed_wallet.list_addresses[i].address_id) + expect(new_wallet.addresses.length).to eq(address_count) + new_wallet.addresses.each_with_index do |address, i| + expect(address.id).to eq(seed_wallet.addresses[i].id) end end end + + describe '#inspect' do + it 'includes wallet details' do + expect(wallet.inspect).to include(wallet_id, Coinbase.to_sym(network_id).to_s, address_model.address_id) + end + + it 'returns the same value as to_s' do + expect(wallet.inspect).to eq(wallet.to_s) + end + end end