coinbase-sdk 0.0.1 → 0.0.3

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