coinbase-sdk 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coinbase/address.rb +152 -51
  3. data/lib/coinbase/asset.rb +2 -1
  4. data/lib/coinbase/authenticator.rb +52 -0
  5. data/lib/coinbase/balance_map.rb +2 -2
  6. data/lib/coinbase/client/api/addresses_api.rb +454 -0
  7. data/lib/coinbase/client/api/transfers_api.rb +342 -0
  8. data/lib/coinbase/client/api/users_api.rb +79 -0
  9. data/lib/coinbase/client/api/wallets_api.rb +348 -0
  10. data/lib/coinbase/client/api_client.rb +431 -0
  11. data/lib/coinbase/client/api_error.rb +58 -0
  12. data/lib/coinbase/client/configuration.rb +375 -0
  13. data/lib/coinbase/client/models/address.rb +273 -0
  14. data/lib/coinbase/client/models/address_balance_list.rb +275 -0
  15. data/lib/coinbase/client/models/address_list.rb +275 -0
  16. data/lib/coinbase/client/models/asset.rb +260 -0
  17. data/lib/coinbase/client/models/balance.rb +239 -0
  18. data/lib/coinbase/client/models/broadcast_transfer_request.rb +222 -0
  19. data/lib/coinbase/client/models/create_address_request.rb +239 -0
  20. data/lib/coinbase/client/models/create_transfer_request.rb +273 -0
  21. data/lib/coinbase/client/models/create_wallet_request.rb +221 -0
  22. data/lib/coinbase/client/models/error.rb +278 -0
  23. data/lib/coinbase/client/models/faucet_transaction.rb +222 -0
  24. data/lib/coinbase/client/models/transfer.rb +413 -0
  25. data/lib/coinbase/client/models/transfer_list.rb +275 -0
  26. data/lib/coinbase/client/models/user.rb +231 -0
  27. data/lib/coinbase/client/models/wallet.rb +241 -0
  28. data/lib/coinbase/client/models/wallet_list.rb +275 -0
  29. data/lib/coinbase/client/version.rb +15 -0
  30. data/lib/coinbase/client.rb +59 -0
  31. data/lib/coinbase/constants.rb +8 -2
  32. data/lib/coinbase/errors.rb +120 -0
  33. data/lib/coinbase/faucet_transaction.rb +42 -0
  34. data/lib/coinbase/middleware.rb +21 -0
  35. data/lib/coinbase/network.rb +2 -2
  36. data/lib/coinbase/transfer.rb +106 -65
  37. data/lib/coinbase/user.rb +180 -0
  38. data/lib/coinbase/wallet.rb +168 -52
  39. data/lib/coinbase.rb +127 -9
  40. metadata +92 -6
@@ -3,15 +3,14 @@
3
3
  require_relative 'constants'
4
4
  require 'bigdecimal'
5
5
  require 'eth'
6
+ require 'json'
6
7
 
7
8
  module Coinbase
8
9
  # A representation of a Transfer, which moves an amount of an Asset from
9
10
  # a user-controlled Wallet to another address. The fee is assumed to be paid
10
- # in the native Asset of the Network. Currently only ETH transfers are supported. Transfers
11
- # should be created through {link:Wallet#transfer} or {link:Address#transfer}.
11
+ # in the native Asset of the Network. Transfers should be created through Wallet#transfer or
12
+ # Address#transfer.
12
13
  class Transfer
13
- attr_reader :network_id, :wallet_id, :from_address_id, :amount, :asset_id, :to_address_id
14
-
15
14
  # A representation of a Transfer status.
16
15
  module Status
17
16
  # The Transfer is awaiting being broadcast to the Network. At this point, transaction
@@ -29,28 +28,83 @@ module Coinbase
29
28
  FAILED = :failed
30
29
  end
31
30
 
32
- # Returns a new Transfer object.
33
- # @param network_id [Symbol] The ID of the Network on which the Transfer originated
34
- # @param wallet_id [String] The ID of the Wallet from which the Transfer originated
35
- # @param from_address_id [String] The ID of the address from which the Transfer originated
36
- # @param amount [Integer, Float, BigDecimal] The amount of the Asset to send. Integers are interpreted as
37
- # the smallest denomination of the Asset (e.g. Wei for Ether). Floats and BigDecimals are interpreted as the Asset
38
- # itself (e.g. Ether).
39
- # @param asset_id [Symbol] The ID of the Asset being transferred. Currently only ETH is supported.
40
- # @param to_address_id [String] The address to which the Transfer is being sent
41
- # @param client [Jimson::Client] (Optional) The JSON RPC client to use for interacting with the Network
42
- def initialize(network_id, wallet_id, from_address_id, amount, asset_id, to_address_id,
43
- client: Jimson::Client.new(Coinbase.base_sepolia_rpc_url))
31
+ # Returns a new Transfer object. Do not use this method directly. Instead, use Wallet#transfer or
32
+ # Address#transfer.
33
+ # @param model [Coinbase::Client::Transfer] The underlying Transfer object
34
+ def initialize(model)
35
+ @model = model
36
+ end
37
+
38
+ # Returns the Transfer ID.
39
+ # @return [String] The Transfer ID
40
+ def transfer_id
41
+ @model.transfer_id
42
+ end
43
+
44
+ # Returns the Network ID of the Transfer.
45
+ # @return [Symbol] The Network ID
46
+ def network_id
47
+ Coinbase.to_sym(@model.network_id)
48
+ end
49
+
50
+ # Returns the Wallet ID of the Transfer.
51
+ # @return [String] The Wallet ID
52
+ def wallet_id
53
+ @model.wallet_id
54
+ end
55
+
56
+ # Returns the From Address ID of the Transfer.
57
+ # @return [String] The From Address ID
58
+ def from_address_id
59
+ @model.address_id
60
+ end
61
+
62
+ # Returns the Destination Address ID of the Transfer.
63
+ # @return [String] The Destination Address ID
64
+ def destination_address_id
65
+ @model.destination
66
+ end
67
+
68
+ # Returns the Asset ID of the Transfer.
69
+ # @return [Symbol] The Asset ID
70
+ def asset_id
71
+ @model.asset_id.to_sym
72
+ end
73
+
74
+ # Returns the amount of the asset for the Transfer.
75
+ # @return [BigDecimal] The amount of the asset
76
+ def amount
77
+ case asset_id
78
+ when :eth
79
+ BigDecimal(@model.amount) / BigDecimal(Coinbase::WEI_PER_ETHER.to_s)
80
+ else
81
+ BigDecimal(@model.amount)
82
+ end
83
+ end
84
+
85
+ # Returns the link to the transaction on the blockchain explorer.
86
+ # @return [String] The link to the transaction on the blockchain explorer
87
+ def transaction_link
88
+ # TODO: Parameterize this by Network.
89
+ "https://sepolia.basescan.org/tx/#{transaction_hash}"
90
+ end
44
91
 
45
- raise ArgumentError, "Unsupported asset: #{asset_id}" if asset_id != :eth
92
+ # Returns the Unsigned Payload of the Transfer.
93
+ # @return [String] The Unsigned Payload
94
+ def unsigned_payload
95
+ @model.unsigned_payload
96
+ end
46
97
 
47
- @network_id = network_id
48
- @wallet_id = wallet_id
49
- @from_address_id = from_address_id
50
- @amount = normalize_eth_amount(amount)
51
- @asset_id = asset_id
52
- @to_address_id = to_address_id
53
- @client = client
98
+ # Returns the Signed Payload of the Transfer.
99
+ # @return [String] The Signed Payload
100
+ def signed_payload
101
+ @model.signed_payload
102
+ end
103
+
104
+ # Returns the Transaction Hash of the Transfer.
105
+ # @return [String] The Transaction Hash
106
+ def transaction_hash
107
+ @model.transaction_hash
54
108
  end
55
109
 
56
110
  # Returns the underlying Transfer transaction, creating it if it has not been yet.
@@ -58,18 +112,19 @@ module Coinbase
58
112
  def transaction
59
113
  return @transaction unless @transaction.nil?
60
114
 
61
- nonce = @client.eth_getTransactionCount(@from_address_id.to_s, 'latest').to_i(16)
62
- gas_price = @client.eth_gasPrice.to_i(16)
115
+ raw_payload = [unsigned_payload].pack('H*')
116
+ parsed_payload = JSON.parse(raw_payload)
63
117
 
64
118
  params = {
65
- chain_id: BASE_SEPOLIA.chain_id, # TODO: Don't hardcode Base Sepolia.
66
- nonce: nonce,
67
- priority_fee: gas_price, # TODO: Optimize this.
68
- max_gas_fee: gas_price,
69
- gas_limit: 21_000, # TODO: Handle multiple currencies.
70
- from: Eth::Address.new(@from_address_id),
71
- to: Eth::Address.new(@to_address_id),
72
- value: (@amount * Coinbase::WEI_PER_ETHER).to_i
119
+ chain_id: parsed_payload['chainId'].to_i(16),
120
+ nonce: parsed_payload['nonce'].to_i(16),
121
+ priority_fee: parsed_payload['maxPriorityFeePerGas'].to_i(16),
122
+ max_gas_fee: parsed_payload['maxFeePerGas'].to_i(16),
123
+ gas_limit: parsed_payload['gas'].to_i(16), # TODO: Handle multiple currencies.
124
+ from: Eth::Address.new(from_address_id),
125
+ to: Eth::Address.new(parsed_payload['to']),
126
+ value: parsed_payload['value'].to_i(16),
127
+ data: parsed_payload['input'] || ''
73
128
  }
74
129
 
75
130
  @transaction = Eth::Tx::Eip1559.new(Eth::Tx.validate_eip1559_params(params))
@@ -79,15 +134,10 @@ module Coinbase
79
134
  # Returns the status of the Transfer.
80
135
  # @return [Symbol] The status
81
136
  def status
82
- begin
83
- # Create the transaction, and attempt to get the hash to see if it has been signed.
84
- transaction.hash
85
- rescue Eth::Signature::SignatureError
86
- # If the transaction has not been signed, it is still pending.
87
- return Status::PENDING
88
- end
137
+ # Check if the transfer has been signed yet.
138
+ return Status::PENDING if transaction_hash.nil?
89
139
 
90
- onchain_transaction = @client.eth_getTransactionByHash(transaction_hash)
140
+ onchain_transaction = Coinbase.configuration.base_sepolia_client.eth_getTransactionByHash(transaction_hash)
91
141
 
92
142
  if onchain_transaction.nil?
93
143
  # If the transaction has not been broadcast, it is still pending.
@@ -97,7 +147,7 @@ module Coinbase
97
147
  # broadcast.
98
148
  Status::BROADCAST
99
149
  else
100
- transaction_receipt = @client.eth_getTransactionReceipt(transaction_hash)
150
+ transaction_receipt = Coinbase.configuration.base_sepolia_client.eth_getTransactionReceipt(transaction_hash)
101
151
 
102
152
  if transaction_receipt['status'].to_i(16) == 1
103
153
  Status::COMPLETE
@@ -126,28 +176,19 @@ module Coinbase
126
176
  self
127
177
  end
128
178
 
129
- # Returns the transaction hash of the Transfer, or nil if not yet available.
130
- # @return [String] The transaction hash
131
- def transaction_hash
132
- "0x#{transaction.hash}"
133
- rescue Eth::Signature::SignatureError
134
- nil
135
- end
136
-
137
- private
138
-
139
- # Normalizes the given Ether amount into a BigDecimal.
140
- # @param amount [Integer, Float, BigDecimal] The amount to normalize
141
- # @return [BigDecimal] The normalized amount
142
- def normalize_eth_amount(amount)
143
- case amount
144
- when BigDecimal
145
- amount
146
- when Integer, Float
147
- BigDecimal(amount.to_s)
148
- else
149
- raise ArgumentError, "Invalid amount: #{amount}"
150
- end
179
+ # Returns a String representation of the Transfer.
180
+ # @return [String] a String representation of the Transfer
181
+ def to_s
182
+ "Coinbase::Transfer{transfer_id: '#{transfer_id}', network_id: '#{network_id}', " \
183
+ "from_address_id: '#{from_address_id}', destination_address_id: '#{destination_address_id}', " \
184
+ "asset_id: '#{asset_id}', amount: '#{amount}', transaction_hash: '#{transaction_hash}', " \
185
+ "transaction_link: '#{transaction_link}', status: '#{status}'}"
186
+ end
187
+
188
+ # Same as to_s.
189
+ # @return [String] a String representation of the Transfer
190
+ def inspect
191
+ to_s
151
192
  end
152
193
  end
153
194
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'client'
4
+ require_relative 'wallet'
5
+
6
+ module Coinbase
7
+ # A representation of a User. Users have Wallets, which can hold balances of Assets. Access the default User through
8
+ # Coinbase#default_user.
9
+ class User
10
+ # Returns a new User object. Do not use this method directly. Instead, use Coinbase#default_user.
11
+ # @param model [Coinbase::Client::User] the underlying User object
12
+ def initialize(model)
13
+ @model = model
14
+ end
15
+
16
+ # Returns the User ID.
17
+ # @return [String] the User ID
18
+ def user_id
19
+ @model.id
20
+ end
21
+
22
+ # Creates a new Wallet belonging to the User.
23
+ # @return [Coinbase::Wallet] the new Wallet
24
+ def create_wallet
25
+ create_wallet_request = {
26
+ wallet: {
27
+ # TODO: Don't hardcode this.
28
+ network_id: 'base-sepolia'
29
+ }
30
+ }
31
+ opts = { create_wallet_request: create_wallet_request }
32
+
33
+ model = Coinbase.call_api do
34
+ wallets_api.create_wallet(opts)
35
+ end
36
+
37
+ Wallet.new(model)
38
+ end
39
+
40
+ # Imports a Wallet belonging to the User.
41
+ # @param data [Coinbase::Wallet::Data] the Wallet data to import
42
+ # @return [Coinbase::Wallet] the imported Wallet
43
+ def import_wallet(data)
44
+ model = Coinbase.call_api do
45
+ wallets_api.get_wallet(data.wallet_id)
46
+ end
47
+
48
+ address_count = Coinbase.call_api do
49
+ addresses_api.list_addresses(model.id).total_count
50
+ end
51
+
52
+ Wallet.new(model, seed: data.seed, address_count: address_count)
53
+ end
54
+
55
+ # Lists the IDs of the Wallets belonging to the User.
56
+ # @return [Array<String>] the IDs of the Wallets belonging to the User
57
+ def list_wallet_ids
58
+ wallets = Coinbase.call_api do
59
+ wallets_api.list_wallets
60
+ end
61
+
62
+ wallets.data.map(&:id)
63
+ end
64
+
65
+ # Saves a wallet to local file system. Wallet saved this way can be re-instantiated with `load_wallets` function,
66
+ # provided the backup_file is available. This is an insecure method of storing wallet seeds and should only be used
67
+ # for development purposes. If you call save_wallet twice with wallets containing the same wallet_id, the backup
68
+ # will be overwritten during the second attempt.
69
+ # The default backup_file is `seeds.json` in the root folder. It can be configured by changing
70
+ # Coinbase.configuration.backup_file_path.
71
+ #
72
+ # @param wallet [Coinbase::Wallet] The wallet model to save.
73
+ # @param encrypt [bool] (Optional) Boolean representing whether the backup persisted to local file system should be
74
+ # encrypted or not. Data is unencrypted by default.
75
+ # @return [Coinbase::Wallet] the saved wallet.
76
+ def save_wallet(wallet, encrypt: false)
77
+ existing_seeds_in_store = existing_seeds
78
+ data = wallet.export
79
+ seed_to_store = data.seed
80
+ auth_tag = ''
81
+ iv = ''
82
+ if encrypt
83
+ shared_secret = store_encryption_key
84
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
85
+ cipher.key = OpenSSL::Digest.digest('SHA256', shared_secret)
86
+ iv = cipher.random_iv
87
+ cipher.iv = iv
88
+ cipher.auth_data = ''
89
+ encrypted_data = cipher.update(data.seed) + cipher.final
90
+ auth_tag = cipher.auth_tag.unpack1('H*')
91
+ iv = iv.unpack1('H*')
92
+ seed_to_store = encrypted_data.unpack1('H*')
93
+ end
94
+
95
+ existing_seeds_in_store[data.wallet_id] = {
96
+ seed: seed_to_store,
97
+ encrypted: encrypt,
98
+ auth_tag: auth_tag,
99
+ iv: iv
100
+ }
101
+
102
+ File.open(Coinbase.configuration.backup_file_path, 'w') do |file|
103
+ file.write(JSON.pretty_generate(existing_seeds_in_store))
104
+ end
105
+ wallet
106
+ end
107
+
108
+ # Loads all wallets belonging to the User with backup persisted to the local file system.
109
+ # @return [Map<String>Coinbase::Wallet] the map of wallet_ids to the wallets.
110
+ def load_wallets
111
+ existing_seeds_in_store = existing_seeds
112
+ raise ArgumentError, 'Backup file not found' if existing_seeds_in_store == {}
113
+
114
+ wallets = {}
115
+ existing_seeds_in_store.each do |wallet_id, seed_data|
116
+ seed = seed_data['seed']
117
+ raise ArgumentError, 'Malformed backup data' if seed.nil? || seed == ''
118
+
119
+ if seed_data['encrypted']
120
+ shared_secret = store_encryption_key
121
+ raise ArgumentError, 'Malformed encrypted seed data' if seed_data['iv'] == '' ||
122
+ seed_data['auth_tag'] == ''
123
+
124
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
125
+ cipher.key = OpenSSL::Digest.digest('SHA256', shared_secret)
126
+ iv = [seed_data['iv']].pack('H*')
127
+ cipher.iv = iv
128
+ auth_tag = [seed_data['auth_tag']].pack('H*')
129
+ cipher.auth_tag = auth_tag
130
+ cipher.auth_data = ''
131
+ hex_decoded_data = [seed_data['seed']].pack('H*')
132
+ seed = cipher.update(hex_decoded_data) + cipher.final
133
+ end
134
+
135
+ data = Coinbase::Wallet::Data.new(wallet_id: wallet_id, seed: seed)
136
+ wallets[wallet_id] = import_wallet(data)
137
+ end
138
+ wallets
139
+ end
140
+
141
+ # Returns a string representation of the User.
142
+ # @return [String] a string representation of the User
143
+ def to_s
144
+ "Coinbase::User{user_id: '#{user_id}'}"
145
+ end
146
+
147
+ # Same as to_s.
148
+ # @return [String] a string representation of the User
149
+ def inspect
150
+ to_s
151
+ end
152
+
153
+ private
154
+
155
+ def addresses_api
156
+ @addresses_api ||= Coinbase::Client::AddressesApi.new(Coinbase.configuration.api_client)
157
+ end
158
+
159
+ def wallets_api
160
+ @wallets_api ||= Coinbase::Client::WalletsApi.new(Coinbase.configuration.api_client)
161
+ end
162
+
163
+ def existing_seeds
164
+ existing_seed_data = '{}'
165
+ file_path = Coinbase.configuration.backup_file_path
166
+ existing_seed_data = File.read(file_path) if File.exist?(file_path)
167
+ output = JSON.parse(existing_seed_data)
168
+
169
+ raise ArgumentError, 'Malformed backup data' unless output.is_a?(Hash)
170
+
171
+ output
172
+ end
173
+
174
+ def store_encryption_key
175
+ pk = OpenSSL::PKey.read(Coinbase.configuration.api_key_private_key)
176
+ public_key = pk.public_key # use own public key to generate the shared secret.
177
+ pk.dh_compute_key(public_key)
178
+ end
179
+ end
180
+ end