coinbase-sdk 0.0.4 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
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