coinbase-sdk 0.0.4 → 0.0.6

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coinbase/address.rb +79 -42
  3. data/lib/coinbase/authenticator.rb +1 -1
  4. data/lib/coinbase/balance.rb +3 -5
  5. data/lib/coinbase/balance_map.rb +4 -4
  6. data/lib/coinbase/client/api/server_signers_api.rb +419 -0
  7. data/lib/coinbase/client/api/trades_api.rb +342 -0
  8. data/lib/coinbase/client/models/broadcast_trade_request.rb +222 -0
  9. data/lib/coinbase/client/models/create_address_request.rb +0 -14
  10. data/lib/coinbase/client/models/create_server_signer_request.rb +239 -0
  11. data/lib/coinbase/client/models/create_trade_request.rb +256 -0
  12. data/lib/coinbase/client/models/create_wallet_request.rb +1 -1
  13. data/lib/coinbase/client/models/create_wallet_request_wallet.rb +233 -0
  14. data/lib/coinbase/client/models/seed_creation_event.rb +240 -0
  15. data/lib/coinbase/client/models/seed_creation_event_result.rb +274 -0
  16. data/lib/coinbase/client/models/server_signer.rb +235 -0
  17. data/lib/coinbase/client/models/server_signer_event.rb +239 -0
  18. data/lib/coinbase/client/models/server_signer_event_event.rb +105 -0
  19. data/lib/coinbase/client/models/server_signer_event_list.rb +275 -0
  20. data/lib/coinbase/client/models/signature_creation_event.rb +363 -0
  21. data/lib/coinbase/client/models/signature_creation_event_result.rb +329 -0
  22. data/lib/coinbase/client/models/trade.rb +356 -0
  23. data/lib/coinbase/client/models/trade_list.rb +275 -0
  24. data/lib/coinbase/client/models/transaction.rb +294 -0
  25. data/lib/coinbase/client/models/transaction_type.rb +39 -0
  26. data/lib/coinbase/client/models/wallet.rb +55 -4
  27. data/lib/coinbase/client.rb +18 -0
  28. data/lib/coinbase/middleware.rb +4 -1
  29. data/lib/coinbase/transfer.rb +21 -21
  30. data/lib/coinbase/user.rb +43 -104
  31. data/lib/coinbase/wallet.rb +312 -58
  32. data/lib/coinbase.rb +16 -4
  33. metadata +48 -2
data/lib/coinbase/user.rb CHANGED
@@ -20,21 +20,14 @@ module Coinbase
20
20
  end
21
21
 
22
22
  # Creates a new Wallet belonging to the User.
23
+ # @param network_id [String] (Optional) the ID of the blockchain network. Defaults to 'base-sepolia'.
23
24
  # @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
25
+ def create_wallet(create_wallet_options = {})
26
+ # For ruby 2.7 compatibility we cannot pass in keyword args when the create wallet
27
+ # options is empty
28
+ return Wallet.create if create_wallet_options.empty?
36
29
 
37
- Wallet.new(model)
30
+ Wallet.create(**create_wallet_options)
38
31
  end
39
32
 
40
33
  # Imports a Wallet belonging to the User.
@@ -44,90 +37,53 @@ module Coinbase
44
37
  Wallet.import(data)
45
38
  end
46
39
 
47
- # Lists the IDs of the Wallets belonging to the User.
48
- # @return [Array<String>] the IDs of the Wallets belonging to the User
49
- def wallet_ids
50
- wallets = Coinbase.call_api do
51
- wallets_api.list_wallets
52
- end
40
+ # Lists the Wallets belonging to the User.
41
+ # @param page_size [Integer] (Optional) the number of Wallets to return per page. Defaults to 10
42
+ # @param next_page_token [String] (Optional) the token for the next page of Wallets
43
+ # @return [Array<Coinbase::Wallet, String>] the Wallets belonging to the User and the pagination token, if
44
+ # any.
45
+ def wallets(page_size: 10, next_page_token: nil)
46
+ opts = {
47
+ limit: page_size
48
+ }
53
49
 
54
- wallets.data.map(&:id)
55
- end
50
+ opts[:page] = next_page_token unless next_page_token.nil?
56
51
 
57
- # Saves a wallet to local file system. Wallet saved this way can be re-instantiated with load_wallets_from_local
58
- # function, provided the backup_file is available. This is an insecure method of storing wallet seeds and should
59
- # only be used for development purposes. If you call save_wallet_locally! twice with wallets containing the same
60
- # wallet_id, the backup will be overwritten during the second attempt.
61
- # The default backup_file is `seeds.json` in the root folder. It can be configured by changing
62
- # Coinbase.configuration.backup_file_path.
63
- #
64
- # @param wallet [Coinbase::Wallet] The wallet model to save.
65
- # @param encrypt [bool] (Optional) Boolean representing whether the backup persisted to local file system should be
66
- # encrypted or not. Data is unencrypted by default.
67
- # @return [Coinbase::Wallet] the saved wallet.
68
- def save_wallet_locally!(wallet, encrypt: false)
69
- existing_seeds_in_store = existing_seeds
70
- data = wallet.export
71
- seed_to_store = data.seed
72
- auth_tag = ''
73
- iv = ''
74
- if encrypt
75
- shared_secret = store_encryption_key
76
- cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
77
- cipher.key = OpenSSL::Digest.digest('SHA256', shared_secret)
78
- iv = cipher.random_iv
79
- cipher.iv = iv
80
- cipher.auth_data = ''
81
- encrypted_data = cipher.update(data.seed) + cipher.final
82
- auth_tag = cipher.auth_tag.unpack1('H*')
83
- iv = iv.unpack1('H*')
84
- seed_to_store = encrypted_data.unpack1('H*')
52
+ wallet_list = Coinbase.call_api do
53
+ wallets_api.list_wallets(opts)
85
54
  end
86
55
 
87
- existing_seeds_in_store[data.wallet_id] = {
88
- seed: seed_to_store,
89
- encrypted: encrypt,
90
- auth_tag: auth_tag,
91
- iv: iv
92
- }
56
+ # A map from wallet_id to address models.
57
+ address_model_map = {}
58
+
59
+ wallet_list.data.each do |wallet_model|
60
+ addresses_list = Coinbase.call_api do
61
+ addresses_api.list_addresses(wallet_model.id, { limit: Coinbase::Wallet::MAX_ADDRESSES })
62
+ end
63
+
64
+ address_model_map[wallet_model.id] = addresses_list.data
65
+ end
93
66
 
94
- File.open(Coinbase.configuration.backup_file_path, 'w') do |file|
95
- file.write(JSON.pretty_generate(existing_seeds_in_store))
67
+ wallets = wallet_list.data.map do |wallet_model|
68
+ Wallet.new(wallet_model, seed: '', address_models: address_model_map[wallet_model.id])
96
69
  end
97
- wallet
70
+
71
+ [wallets, wallet_list.next_page]
98
72
  end
99
73
 
100
- # Loads all wallets belonging to the User with backup persisted to the local file system.
101
- # @return [Map<String>Coinbase::Wallet] the map of wallet_ids to the wallets.
102
- def load_wallets_from_local
103
- existing_seeds_in_store = existing_seeds
104
- raise ArgumentError, 'Backup file not found' if existing_seeds_in_store == {}
105
-
106
- wallets = {}
107
- existing_seeds_in_store.each do |wallet_id, seed_data|
108
- seed = seed_data['seed']
109
- raise ArgumentError, 'Malformed backup data' if seed.nil? || seed == ''
110
-
111
- if seed_data['encrypted']
112
- shared_secret = store_encryption_key
113
- raise ArgumentError, 'Malformed encrypted seed data' if seed_data['iv'] == '' ||
114
- seed_data['auth_tag'] == ''
115
-
116
- cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
117
- cipher.key = OpenSSL::Digest.digest('SHA256', shared_secret)
118
- iv = [seed_data['iv']].pack('H*')
119
- cipher.iv = iv
120
- auth_tag = [seed_data['auth_tag']].pack('H*')
121
- cipher.auth_tag = auth_tag
122
- cipher.auth_data = ''
123
- hex_decoded_data = [seed_data['seed']].pack('H*')
124
- seed = cipher.update(hex_decoded_data) + cipher.final
125
- end
74
+ # Returns the Wallet with the given ID.
75
+ # @param wallet_id [String] the ID of the Wallet
76
+ # @return [Coinbase::Wallet] the unhydrated Wallet
77
+ def wallet(wallet_id)
78
+ wallet_model = Coinbase.call_api do
79
+ wallets_api.get_wallet(wallet_id)
80
+ end
126
81
 
127
- data = Coinbase::Wallet::Data.new(wallet_id: wallet_id, seed: seed)
128
- wallets[wallet_id] = import_wallet(data)
82
+ addresses_list = Coinbase.call_api do
83
+ addresses_api.list_addresses(wallet_model.id, { limit: Coinbase::Wallet::MAX_ADDRESSES })
129
84
  end
130
- wallets
85
+
86
+ Wallet.new(wallet_model, seed: '', address_models: addresses_list.data)
131
87
  end
132
88
 
133
89
  # Returns a string representation of the User.
@@ -151,22 +107,5 @@ module Coinbase
151
107
  def wallets_api
152
108
  @wallets_api ||= Coinbase::Client::WalletsApi.new(Coinbase.configuration.api_client)
153
109
  end
154
-
155
- def existing_seeds
156
- existing_seed_data = '{}'
157
- file_path = Coinbase.configuration.backup_file_path
158
- existing_seed_data = File.read(file_path) if File.exist?(file_path)
159
- output = JSON.parse(existing_seed_data)
160
-
161
- raise ArgumentError, 'Malformed backup data' unless output.is_a?(Hash)
162
-
163
- output
164
- end
165
-
166
- def store_encryption_key
167
- pk = OpenSSL::PKey.read(Coinbase.configuration.api_key_private_key)
168
- public_key = pk.public_key # use own public key to generate the shared secret.
169
- pk.dh_compute_key(public_key)
170
- end
171
110
  end
172
111
  end
@@ -12,7 +12,21 @@ module Coinbase
12
12
  # list their balances, and transfer Assets to other Addresses. Wallets should be created through User#create_wallet or
13
13
  # User#import_wallet.
14
14
  class Wallet
15
- attr_reader :addresses
15
+ attr_reader :addresses, :model
16
+
17
+ # The maximum number of addresses in a Wallet.
18
+ MAX_ADDRESSES = 20
19
+
20
+ # A representation of ServerSigner status in a Wallet.
21
+ module ServerSignerStatus
22
+ # The Wallet is awaiting seed creation by the ServerSigner. At this point,
23
+ # the Wallet cannot create addresses or sign transactions.
24
+ PENDING = 'pending_seed_creation'
25
+
26
+ # The Wallet has an associated seed created by the ServerSigner. It is ready
27
+ # to create addresses and sign transactions.
28
+ ACTIVE = 'active_seed'
29
+ end
16
30
 
17
31
  class << self
18
32
  # Imports a Wallet from previously exported wallet data.
@@ -25,16 +39,70 @@ module Coinbase
25
39
  wallets_api.get_wallet(data.wallet_id)
26
40
  end
27
41
 
28
- # TODO: Pass these addresses in directly
29
- address_count = Coinbase.call_api do
30
- addresses_api.list_addresses(model.id).total_count
42
+ address_list = Coinbase.call_api do
43
+ addresses_api.list_addresses(model.id, { limit: MAX_ADDRESSES })
31
44
  end
32
45
 
33
- new(model, seed: data.seed, address_count: address_count)
46
+ new(model, seed: data.seed, address_models: address_list.data)
47
+ end
48
+
49
+ # Creates a new Wallet on the specified Network and generate a default address for it.
50
+ # @param network_id [String] (Optional) the ID of the blockchain network. Defaults to 'base-sepolia'.
51
+ # @param interval_seconds [Integer] The interval at which to poll the CDPService for the Wallet to
52
+ # have an active seed, if using a ServerSigner, in seconds
53
+ # @param timeout_seconds [Integer] The maximum amount of time to wait for the ServerSigner to
54
+ # create a seed for the Wallet, in seconds
55
+ # @return [Coinbase::Wallet] the new Wallet
56
+ def create(network_id: 'base-sepolia', interval_seconds: 0.2, timeout_seconds: 20)
57
+ model = Coinbase.call_api do
58
+ wallets_api.create_wallet(
59
+ create_wallet_request: {
60
+ wallet: {
61
+ network_id: network_id,
62
+ use_server_signer: Coinbase.use_server_signer?
63
+ }
64
+ }
65
+ )
66
+ end
67
+
68
+ wallet = new(model)
69
+
70
+ # When used with a ServerSigner, the Signer must first register
71
+ # with the Wallet before addresses can be created.
72
+ wait_for_signer(wallet.id, interval_seconds, timeout_seconds) if Coinbase.use_server_signer?
73
+
74
+ wallet.create_address
75
+ wallet
34
76
  end
35
77
 
36
78
  private
37
79
 
80
+ # Wait_for_signer waits until the ServerSigner has created a seed for the Wallet.
81
+ # Timeout::Error if the ServerSigner takes longer than the given timeout to create the seed.
82
+ # @param wallet_id [string] The ID of the Wallet that is awaiting seed creation.
83
+ # @param interval_seconds [Integer] The interval at which to poll the CDPService, in seconds
84
+ # @param timeout_seconds [Integer] The maximum amount of time to wait for the Signer to create a seed, in seconds
85
+ # @return [Wallet] The completed Wallet object that is ready to create addresses.
86
+ def wait_for_signer(wallet_id, interval_seconds, timeout_seconds)
87
+ start_time = Time.now
88
+
89
+ loop do
90
+ model = Coinbase.call_api do
91
+ wallets_api.get_wallet(wallet_id)
92
+ end
93
+
94
+ return self if model.server_signer_status == ServerSignerStatus::ACTIVE
95
+
96
+ if Time.now - start_time > timeout_seconds
97
+ raise Timeout::Error, 'Wallet creation timed out. Check status of your Server-Signer'
98
+ end
99
+
100
+ self.sleep interval_seconds
101
+ end
102
+
103
+ self
104
+ end
105
+
38
106
  # TODO: Memoize these objects in a thread-safe way at the top-level.
39
107
  def addresses_api
40
108
  Coinbase::Client::AddressesApi.new(Coinbase.configuration.api_client)
@@ -49,31 +117,23 @@ module Coinbase
49
117
  # User#import_wallet.
50
118
  # @param model [Coinbase::Client::Wallet] The underlying Wallet object
51
119
  # @param seed [String] (Optional) The seed to use for the Wallet. Expects a 32-byte hexadecimal with no 0x prefix.
52
- # If not provided, a new seed will be generated.
53
- # @param address_count [Integer] (Optional) The number of addresses already registered for the Wallet.
120
+ # If nil, a new seed will be generated. If the empty string, no seed is generated, and the Wallet will be
121
+ # instantiated without a seed and its corresponding private keys.
122
+ # @param address_models [Array<Coinbase::Client::Address>] (Optional) The models of the addresses already registered
123
+ # with the Wallet. If not provided, the Wallet will derive the first default address.
54
124
  # @param client [Jimson::Client] (Optional) The JSON RPC client to use for interacting with the Network
55
- def initialize(model, seed: nil, address_count: 0)
56
- raise ArgumentError, 'Seed must be 32 bytes' if !seed.nil? && seed.length != 64
125
+ def initialize(model, seed: nil, address_models: [])
126
+ validate_seed_and_address_models(seed, address_models) unless Coinbase.use_server_signer?
57
127
 
58
128
  @model = model
59
-
60
- @master = seed.nil? ? MoneyTree::Master.new : MoneyTree::Master.new(seed_hex: seed)
61
-
62
- # TODO: Make Network an argument to the constructor.
63
- @network_id = :base_sepolia
64
129
  @addresses = []
65
130
 
66
- # TODO: Adjust derivation path prefix based on network protocol.
67
- @address_path_prefix = "m/44'/60'/0'/0"
68
- @address_index = 0
69
-
70
- if address_count.positive?
71
- address_count.times { derive_address }
72
- else
73
- create_address
74
- # Update the model to reflect the new default address.
75
- update_model
131
+ unless Coinbase.use_server_signer?
132
+ @master = master_node(seed)
133
+ @private_key_index = 0
76
134
  end
135
+
136
+ derive_addresses(address_models)
77
137
  end
78
138
 
79
139
  # Returns the Wallet ID.
@@ -88,30 +148,60 @@ module Coinbase
88
148
  Coinbase.to_sym(@model.network_id)
89
149
  end
90
150
 
151
+ # Returns the ServerSigner Status of the Wallet.
152
+ # @return [Symbol] The ServerSigner Status
153
+ def server_signer_status
154
+ Coinbase.to_sym(@model.server_signer_status)
155
+ end
156
+
157
+ # Sets the seed of the Wallet. This seed is used to derive keys and sign transactions.
158
+ # @param seed [String] The seed to set. Expects a 32-byte hexadecimal with no 0x prefix.
159
+ def seed=(seed)
160
+ raise ArgumentError, 'Seed must be 32 bytes' if seed.length != 64
161
+ raise 'Seed is already set' unless @master.nil?
162
+ raise 'Cannot set seed for Wallet with non-zero private key index' if @private_key_index.positive?
163
+
164
+ @master = MoneyTree::Master.new(seed_hex: seed)
165
+
166
+ @addresses.each do
167
+ key = derive_key
168
+ a = address(key.address.to_s)
169
+ raise "Seed does not match wallet; cannot find address #{key.address}" if a.nil?
170
+
171
+ a.key = key
172
+ end
173
+ end
174
+
91
175
  # Creates a new Address in the Wallet.
92
176
  # @return [Address] The new Address
93
177
  def create_address
94
- key = derive_key
95
- attestation = create_attestation(key)
96
- public_key = key.public_key.compressed.unpack1('H*')
178
+ opts = { create_address_request: {} }
97
179
 
98
- opts = {
99
- create_address_request: {
100
- public_key: public_key,
101
- attestation: attestation
180
+ unless Coinbase.use_server_signer?
181
+ key = derive_key
182
+
183
+ opts = {
184
+ create_address_request: {
185
+ public_key: key.public_key.compressed.unpack1('H*'),
186
+ attestation: create_attestation(key)
187
+ }
102
188
  }
103
- }
189
+ end
190
+
104
191
  address_model = Coinbase.call_api do
105
192
  addresses_api.create_address(id, opts)
106
193
  end
107
194
 
195
+ # Auto-reload wallet to set default address on first address creation.
196
+ reload if addresses.empty?
197
+
108
198
  cache_address(address_model, key)
109
199
  end
110
200
 
111
201
  # Returns the default address of the Wallet.
112
202
  # @return [Address] The default address
113
203
  def default_address
114
- address(@model.default_address.address_id)
204
+ address(@model.default_address&.address_id)
115
205
  end
116
206
 
117
207
  # Returns the Address with the given ID.
@@ -144,30 +234,25 @@ module Coinbase
144
234
  Coinbase::Balance.from_model_and_asset_id(response, asset_id).amount
145
235
  end
146
236
 
147
- # Transfers the given amount of the given Asset to the given address. Only same-Network Transfers are supported.
148
- # Currently only the default_address is used to source the Transfer.
237
+ # Transfers the given amount of the given Asset to the specified address or wallet.
238
+ # Only same-network Transfers are supported. Currently only the default_address is used to source the Transfer.
149
239
  # @param amount [Integer, Float, BigDecimal] The amount of the Asset to send
150
240
  # @param asset_id [Symbol] The ID of the Asset to send
151
241
  # @param destination [Wallet | Address | String] The destination of the transfer. If a Wallet, sends to the Wallet's
152
242
  # default address. If a String, interprets it as the address ID.
153
243
  # @return [Transfer] The hash of the Transfer transaction.
154
244
  def transfer(amount, asset_id, destination)
155
- if destination.is_a?(Wallet)
156
- raise ArgumentError, 'Transfer must be on the same Network' if destination.network_id != @network_id
157
-
158
- destination = destination.default_address.id
159
- elsif destination.is_a?(Address)
160
- raise ArgumentError, 'Transfer must be on the same Network' if destination.network_id != @network_id
161
-
162
- destination = destination.id
163
- end
164
-
165
245
  default_address.transfer(amount, asset_id, destination)
166
246
  end
167
247
 
168
248
  # Exports the Wallet's data to a Data object.
169
249
  # @return [Data] The Wallet data
170
250
  def export
251
+ # TODO: Improve this check by relying on the backend data to decide whether a wallet is server-signer backed.
252
+ raise 'Cannot export data for Server-Signer backed Wallet' if Coinbase.use_server_signer?
253
+
254
+ raise 'Cannot export Wallet without loaded seed' if @master.nil?
255
+
171
256
  Data.new(wallet_id: id, seed: @master.seed_hex)
172
257
  end
173
258
 
@@ -182,11 +267,98 @@ module Coinbase
182
267
  end
183
268
  end
184
269
 
270
+ # Returns whether the Wallet has a seed with which to derive keys and sign transactions.
271
+ # @return [Boolean] Whether the Wallet has a seed with which to derive keys and sign transactions.
272
+ def can_sign?
273
+ !@master.nil?
274
+ end
275
+
276
+ # Saves the seed of the Wallet to the given file. Wallets whose seeds are saved this way can be
277
+ # rehydrated using load_seed. A single file can be used for multiple Wallet seeds.
278
+ # This is an insecure method of storing Wallet seeds and should only be used for development purposes.
279
+ #
280
+ # @param file_path [String] The path of the file to save the seed to
281
+ # @param encrypt [bool] (Optional) Whether the seed information persisted to the local file system should be
282
+ # encrypted or not. Data is unencrypted by default.
283
+ # @return [String] A string indicating the success of the operation
284
+ def save_seed!(file_path, encrypt: false)
285
+ raise 'Wallet does not have seed loaded' if @master.nil?
286
+
287
+ existing_seeds_in_store = existing_seeds(file_path)
288
+
289
+ seed_to_store = @master.seed_hex
290
+ auth_tag = ''
291
+ iv = ''
292
+ if encrypt
293
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
294
+ cipher.key = OpenSSL::Digest.digest('SHA256', encryption_key)
295
+ iv = cipher.random_iv
296
+ cipher.iv = iv
297
+ cipher.auth_data = ''
298
+ encrypted_data = cipher.update(@master.seed_hex) + cipher.final
299
+ auth_tag = cipher.auth_tag.unpack1('H*')
300
+ iv = iv.unpack1('H*')
301
+ seed_to_store = encrypted_data.unpack1('H*')
302
+ end
303
+
304
+ existing_seeds_in_store[id] = {
305
+ seed: seed_to_store,
306
+ encrypted: encrypt,
307
+ auth_tag: auth_tag,
308
+ iv: iv
309
+ }
310
+
311
+ File.open(file_path, 'w') do |file|
312
+ file.write(JSON.pretty_generate(existing_seeds_in_store))
313
+ end
314
+
315
+ "Successfully saved seed for wallet #{id} to #{file_path}."
316
+ end
317
+
318
+ # Loads the seed of the Wallet from the given file.
319
+ # @param file_path [String] The path of the file to load the seed from
320
+ # @return [String] A string indicating the success of the operation
321
+ def load_seed(file_path)
322
+ raise 'Wallet already has seed loaded' unless @master.nil?
323
+
324
+ existing_seeds_in_store = existing_seeds(file_path)
325
+
326
+ raise ArgumentError, "File #{file_path} does not contain seed data" if existing_seeds_in_store == {}
327
+
328
+ if existing_seeds_in_store[id].nil?
329
+ raise ArgumentError, "File #{file_path} does not contain seed data for wallet #{id}"
330
+ end
331
+
332
+ seed_data = existing_seeds_in_store[id]
333
+ local_seed = seed_data['seed']
334
+
335
+ raise ArgumentError, 'Seed data is malformed' if local_seed.nil? || local_seed == ''
336
+
337
+ if seed_data['encrypted']
338
+ raise ArgumentError, 'Encrypted seed data is malformed' if seed_data['iv'] == '' ||
339
+ seed_data['auth_tag'] == ''
340
+
341
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
342
+ cipher.key = OpenSSL::Digest.digest('SHA256', encryption_key)
343
+ iv = [seed_data['iv']].pack('H*')
344
+ cipher.iv = iv
345
+ auth_tag = [seed_data['auth_tag']].pack('H*')
346
+ cipher.auth_tag = auth_tag
347
+ cipher.auth_data = ''
348
+ hex_decoded_data = [seed_data['seed']].pack('H*')
349
+ local_seed = cipher.update(hex_decoded_data) + cipher.final
350
+ end
351
+
352
+ self.seed = local_seed
353
+
354
+ "Successfully loaded seed for wallet #{id} from #{file_path}."
355
+ end
356
+
185
357
  # Returns a String representation of the Wallet.
186
358
  # @return [String] a String representation of the Wallet
187
359
  def to_s
188
360
  "Coinbase::Wallet{wallet_id: '#{id}', network_id: '#{network_id}', " \
189
- "default_address: '#{default_address.id}'}"
361
+ "default_address: '#{@model.default_address&.address_id}'}"
190
362
  end
191
363
 
192
364
  # Same as to_s.
@@ -223,14 +395,55 @@ module Coinbase
223
395
 
224
396
  private
225
397
 
398
+ # Reloads the Wallet with the latest data.
399
+ def reload
400
+ @model = Coinbase.call_api do
401
+ wallets_api.get_wallet(id)
402
+ end
403
+ end
404
+
405
+ def master_node(seed)
406
+ return MoneyTree::Master.new if seed.nil?
407
+ return nil if seed.empty?
408
+
409
+ MoneyTree::Master.new(seed_hex: seed)
410
+ end
411
+
412
+ def address_path_prefix
413
+ # TODO: Add support for other networks.
414
+ @address_path_prefix ||= case network_id.to_s.split('_').first
415
+ when 'base'
416
+ "m/44'/60'/0'/0"
417
+ else
418
+ raise ArgumentError, "Unsupported network ID: #{network_id}"
419
+ end
420
+ end
421
+
422
+ # Derives the registered Addresses in the Wallet.
423
+ # @param address_models [Array<Coinbase::Client::Address>] The models of the addresses already registered with the
424
+ # Wallet
425
+ def derive_addresses(address_models)
426
+ return unless address_models.any?
427
+
428
+ # Create a map tracking which addresses are already registered with the Wallet.
429
+ address_map = build_address_map(address_models)
430
+
431
+ address_models.each do |address_model|
432
+ # Derive the addresses using the provided models.
433
+ derive_address(address_map, address_model)
434
+ end
435
+ end
436
+
226
437
  # Derives an already registered Address in the Wallet.
438
+ # @param address_map [Hash<String, Boolean>] The map of registered Address IDs
439
+ # @param address_model [Coinbase::Client::Address] The Address model
227
440
  # @return [Address] The new Address
228
- def derive_address
229
- key = derive_key
441
+ def derive_address(address_map, address_model)
442
+ key = @master.nil? ? nil : derive_key
230
443
 
231
- address_id = key.address.to_s
232
- address_model = Coinbase.call_api do
233
- addresses_api.get_address(id, address_id)
444
+ unless key.nil?
445
+ address_from_key = key.address.to_s
446
+ raise 'Invalid address' if address_map[address_from_key].nil?
234
447
  end
235
448
 
236
449
  cache_address(address_model, key)
@@ -239,8 +452,11 @@ module Coinbase
239
452
  # Derives a key for an already registered Address in the Wallet.
240
453
  # @return [Eth::Key] The new key
241
454
  def derive_key
242
- path = "#{@address_path_prefix}/#{@address_index}"
455
+ raise 'Cannot derive key for Wallet without seed loaded' if @master.nil?
456
+
457
+ path = "#{address_path_prefix}/#{@private_key_index}"
243
458
  private_key = @master.node_for_path(path).private_key.to_hex
459
+ @private_key_index += 1
244
460
  Eth::Key.new(priv: private_key)
245
461
  end
246
462
 
@@ -251,10 +467,22 @@ module Coinbase
251
467
  def cache_address(address_model, key)
252
468
  address = Address.new(address_model, key)
253
469
  @addresses << address
254
- @address_index += 1
255
470
  address
256
471
  end
257
472
 
473
+ # Builds a Hash of the registered Addresses.
474
+ # @param address_models [Array<Coinbase::Client::Address>] The models of the addresses already registered with the
475
+ # Wallet
476
+ # @return [Hash<String, Boolean>] The Hash of registered Addresses
477
+ def build_address_map(address_models)
478
+ address_map = {}
479
+ address_models.each do |address_model|
480
+ address_map[address_model.address_id] = true
481
+ end
482
+
483
+ address_map
484
+ end
485
+
258
486
  # Creates an attestation for the Address currently being created.
259
487
  # @param key [Eth::Key] The private key of the Address
260
488
  # @return [String] The attestation
@@ -279,11 +507,37 @@ module Coinbase
279
507
  new_signature_bytes.pack('C*').unpack1('H*')
280
508
  end
281
509
 
282
- # Updates the Wallet model with the latest data.
283
- def update_model
284
- @model = Coinbase.call_api do
285
- wallets_api.get_wallet(id)
286
- end
510
+ # Validates the seed and address models passed to the constructor.
511
+ # @param seed [String] The seed to use for the Wallet
512
+ # @param address_models [Array<Coinbase::Client::Address>] The models of the addresses already registered with the
513
+ # Wallet
514
+ def validate_seed_and_address_models(seed, address_models)
515
+ raise ArgumentError, 'Seed must be 32 bytes' if !seed.nil? && !seed.empty? && seed.length != 64
516
+
517
+ raise ArgumentError, 'Seed must be present if address_models are provided' if seed.nil? && address_models.any?
518
+
519
+ return unless !seed.nil? && seed.empty? && address_models.empty?
520
+
521
+ raise ArgumentError, 'Seed must be empty if address_models are not provided'
522
+ end
523
+
524
+ # Loads the Hash of Wallet seeds from the given file.
525
+ # @param file_path [String] The path of the file to load the seed from
526
+ # @return [Hash<String, Hash>] The Hash of from Wallet IDs to seed data
527
+ def existing_seeds(file_path)
528
+ existing_seed_data = '{}'
529
+ existing_seed_data = File.read(file_path) if File.exist?(file_path)
530
+ existing_seeds = JSON.parse(existing_seed_data)
531
+ raise ArgumentError, "#{file_path} is malformed, must be a valid JSON object" unless existing_seeds.is_a?(Hash)
532
+
533
+ existing_seeds
534
+ end
535
+
536
+ # Returns the shared secret to use for encrypting the seed.
537
+ def encryption_key
538
+ pk = OpenSSL::PKey.read(Coinbase.configuration.api_key_private_key)
539
+ public_key = pk.public_key # use own public key to generate the shared secret.
540
+ pk.dh_compute_key(public_key)
287
541
  end
288
542
 
289
543
  def addresses_api