From a94768ceb8a2cc6d080c7846250da3040805d846 Mon Sep 17 00:00:00 2001 From: Alex Stone Date: Wed, 11 Dec 2024 08:57:54 -0800 Subject: [PATCH] wip: Support registering external contracts This starts to add support for registering external smart contracts with CDP. This will enable developers to use these register smart contracts to invoke, read, and create webhooks associated with the contract! --- lib/coinbase/smart_contract.rb | 532 ++++++++++++++++++--------------- 1 file changed, 288 insertions(+), 244 deletions(-) diff --git a/lib/coinbase/smart_contract.rb b/lib/coinbase/smart_contract.rb index bd7ae5c7..408dd076 100644 --- a/lib/coinbase/smart_contract.rb +++ b/lib/coinbase/smart_contract.rb @@ -3,256 +3,292 @@ module Coinbase # A representation of a SmartContract on the blockchain. class SmartContract - # Returns a list of ContractEvents for the provided network, contract, and event details. - # @param network_id [Symbol] The network ID - # @param protocol_name [String] The protocol name - # @param contract_address [String] The contract address - # @param contract_name [String] The contract name - # @param event_name [String] The event name - # @param from_block_height [Integer] The start block height - # @param to_block_height [Integer] The end block height - # @return [Enumerable] The contract events - def self.list_events( - network_id:, - protocol_name:, - contract_address:, - contract_name:, - event_name:, - from_block_height:, - to_block_height: - ) - Coinbase::Pagination.enumerate( - lambda { |page| - list_events_page( - network_id, - protocol_name, - contract_address, - contract_name, - event_name, - from_block_height, - to_block_height, - page - ) - } - ) do |contract_event| - Coinbase::ContractEvent.new(contract_event) + class << self + # Returns a list of ContractEvents for the provided network, contract, and event details. + # @param network_id [Symbol] The network ID + # @param protocol_name [String] The protocol name + # @param contract_address [String] The contract address + # @param contract_name [String] The contract name + # @param event_name [String] The event name + # @param from_block_height [Integer] The start block height + # @param to_block_height [Integer] The end block height + # @return [Enumerable] The contract events + def list_events( + network_id:, + protocol_name:, + contract_address:, + contract_name:, + event_name:, + from_block_height:, + to_block_height: + ) + Coinbase::Pagination.enumerate( + lambda { |page| + list_events_page( + network_id, + protocol_name, + contract_address, + contract_name, + event_name, + from_block_height, + to_block_height, + page + ) + } + ) do |contract_event| + Coinbase::ContractEvent.new(contract_event) + end end - end - # Creates a new ERC20 token contract, that can subsequently be deployed to - # the blockchain. - # @param address_id [String] The address ID of deployer - # @param wallet_id [String] The wallet ID of the deployer - # @param name [String] The name of the token - # @param symbol [String] The symbol of the token - # @param total_supply [String] The total supply of the token, denominate in whole units. - # @return [SmartContract] The new ERC20 Token SmartContract object - def self.create_token_contract( - address_id:, - wallet_id:, - name:, - symbol:, - total_supply: - ) - contract = Coinbase.call_api do - smart_contracts_api.create_smart_contract( - wallet_id, - address_id, - { - type: Coinbase::Client::SmartContractType::ERC20, - options: Coinbase::Client::TokenContractOptions.new( - name: name, - symbol: symbol, - total_supply: BigDecimal(total_supply).to_i.to_s - ).to_body - } - ) + # Creates a new ERC20 token contract, that can subsequently be deployed to + # the blockchain. + # @param address_id [String] The address ID of deployer + # @param wallet_id [String] The wallet ID of the deployer + # @param name [String] The name of the token + # @param symbol [String] The symbol of the token + # @param total_supply [String] The total supply of the token, denominate in whole units. + # @return [SmartContract] The new ERC20 Token SmartContract object + def create_token_contract( + address_id:, + wallet_id:, + name:, + symbol:, + total_supply: + ) + contract = Coinbase.call_api do + smart_contracts_api.create_smart_contract( + wallet_id, + address_id, + { + type: Coinbase::Client::SmartContractType::ERC20, + options: Coinbase::Client::TokenContractOptions.new( + name: name, + symbol: symbol, + total_supply: BigDecimal(total_supply).to_i.to_s + ).to_body + } + ) + end + + new(contract) end - new(contract) - end + # Creates a new ERC721 token contract, that can subsequently be deployed to + # the blockchain. + # @param address_id [String] The address ID of deployer + # @param wallet_id [String] The wallet ID of the deployer + # @param name [String] The name of the token + # @param symbol [String] The symbol of the token + # @param base_uri [String] The base URI for the token metadata + # @return [SmartContract] The new ERC721 Token SmartContract object + def create_nft_contract( + address_id:, + wallet_id:, + name:, + symbol:, + base_uri: + ) + contract = Coinbase.call_api do + smart_contracts_api.create_smart_contract( + wallet_id, + address_id, + { + type: Coinbase::Client::SmartContractType::ERC721, + options: Coinbase::Client::NFTContractOptions.new( + name: name, + symbol: symbol, + base_uri: base_uri + ).to_body + } + ) + end - # Creates a new ERC721 token contract, that can subsequently be deployed to - # the blockchain. - # @param address_id [String] The address ID of deployer - # @param wallet_id [String] The wallet ID of the deployer - # @param name [String] The name of the token - # @param symbol [String] The symbol of the token - # @param base_uri [String] The base URI for the token metadata - # @return [SmartContract] The new ERC721 Token SmartContract object - def self.create_nft_contract( - address_id:, - wallet_id:, - name:, - symbol:, - base_uri: - ) - contract = Coinbase.call_api do - smart_contracts_api.create_smart_contract( - wallet_id, - address_id, - { - type: Coinbase::Client::SmartContractType::ERC721, - options: Coinbase::Client::NFTContractOptions.new( - name: name, - symbol: symbol, - base_uri: base_uri - ).to_body - } - ) + new(contract) end - new(contract) - end + # Creates a new ERC1155 multi-token contract, that can subsequently be deployed to + # the blockchain. + # @param address_id [String] The address ID of deployer + # @param wallet_id [String] The wallet ID of the deployer + # @param uri [String] The URI for the token metadata + # @return [SmartContract] The new ERC1155 Multi-Token SmartContract object + def create_multi_token_contract( + address_id:, + wallet_id:, + uri: + ) + contract = Coinbase.call_api do + smart_contracts_api.create_smart_contract( + wallet_id, + address_id, + { + type: Coinbase::Client::SmartContractType::ERC1155, + options: Coinbase::Client::MultiTokenContractOptions.new( + uri: uri + ).to_body + } + ) + end - # Creates a new ERC1155 multi-token contract, that can subsequently be deployed to - # the blockchain. - # @param address_id [String] The address ID of deployer - # @param wallet_id [String] The wallet ID of the deployer - # @param uri [String] The URI for the token metadata - # @return [SmartContract] The new ERC1155 Multi-Token SmartContract object - def self.create_multi_token_contract( - address_id:, - wallet_id:, - uri: - ) - contract = Coinbase.call_api do - smart_contracts_api.create_smart_contract( - wallet_id, - address_id, - { - type: Coinbase::Client::SmartContractType::ERC1155, - options: Coinbase::Client::MultiTokenContractOptions.new( - uri: uri - ).to_body - } - ) + new(contract) end - new(contract) - end + def register( + contract_address:, + abi:, + name:, + network: Coinbase.default_network + ) + network = Coinbase::Network.from_id(network) - # Reads data from a deployed smart contract. - # - # @param network [Coinbase::Network, Symbol] The Network or Network ID of the Asset - # @param contract_address [String] The address of the deployed contract - # @param method [String] The name of the method to call on the contract - # @param abi [Array, nil] The ABI of the contract. If nil, the method will attempt to use a cached ABI - # @param args [Hash] The arguments to pass to the contract method. - # The keys should be the argument names, and the values should be the argument values. - # @return [Object] The result of the contract call, converted to an appropriate Ruby type - # @raise [Coinbase::ApiError] If there's an error in the API call - def self.read( - contract_address:, - method:, - network: Coinbase.default_network, - abi: nil, - args: {} - ) - network = Coinbase::Network.from_id(network) - - response = Coinbase.call_api do - smart_contracts_api.read_contract( - network.normalized_id, - contract_address, - { - method: method, - args: (args || {}).to_json, - abi: abi&.to_json - }.compact - ) + normalized_abi = normalize_abi(abi) + + smart_contract = Coinbase.call_api do + smart_contracts_api.register_smart_contract( + network.normalized_id, + contract_address, + register_smart_contract_request: { + abi: normalized_abi.to_json, + contract_name: name + } + ) + end + + new(smart_contract) end - convert_solidity_value(response) - end + # Reads data from a deployed smart contract. + # + # @param network [Coinbase::Network, Symbol] The Network or Network ID of the Asset + # @param contract_address [String] The address of the deployed contract + # @param method [String] The name of the method to call on the contract + # @param abi [Array, nil] The ABI of the contract. If nil, the method will attempt to use a cached ABI + # @param args [Hash] The arguments to pass to the contract method. + # The keys should be the argument names, and the values should be the argument values. + # @return [Object] The result of the contract call, converted to an appropriate Ruby type + # @raise [Coinbase::ApiError] If there's an error in the API call + def read( + contract_address:, + method:, + network: Coinbase.default_network, + abi: nil, + args: {} + ) + network = Coinbase::Network.from_id(network) - # Converts a Solidity value to an appropriate Ruby type. - # - # @param solidity_value [Coinbase::Client::SolidityValue] The Solidity value to convert - # @return [Object] The converted Ruby value - # @raise [ArgumentError] If an unsupported Solidity type is encountered - # - # This method handles the following Solidity types: - # - Integers (uint8, uint16, uint32, uint64, uint128, uint256, int8, int16, int32, int64, int128, int256) - # - Address - # - String - # - Bytes (including fixed-size byte arrays) - # - Boolean - # - Array - # - Tuple (converted to a Hash) - # - # For complex types like arrays and tuples, the method recursively converts nested values. - def self.convert_solidity_value(solidity_value) - return nil if solidity_value.nil? - - type = solidity_value.type - value = solidity_value.value - values = solidity_value.values - - case type - when 'uint8', 'uint16', 'uint32', 'uint64', 'uint128', 'uint256', - 'int8', 'int16', 'int32', 'int64', 'int128', 'int256' - value&.to_i - when 'address', 'string', /^bytes/ - value - when 'bool' - if value.is_a?(String) - value == 'true' - else - !value.nil? + response = Coinbase.call_api do + smart_contracts_api.read_contract( + network.normalized_id, + contract_address, + { + method: method, + args: (args || {}).to_json, + abi: abi&.to_json + }.compact + ) end - when 'array' - values ? values.map { |v| convert_solidity_value(v) } : [] - when 'tuple' - if values - result = {} - values.each do |v| - raise ArgumentError, 'Error: Tuple value without a name' unless v.respond_to?(:name) - - result[v.name] = convert_solidity_value(v) + + convert_solidity_value(response) + end + + private + + def normalize_abi(abi) + return abi if abi.is_a?(Array) + + raise ArgumentError, 'ABI must be an Array or a JSON string' unless abi.is_a?(String) + + JSON.parse(abi) + rescue JSON::ParserError + raise ArgumentError, 'Invalid ABI JSON' + end + + # smart_contract.read(method: 'balanceOf', args: { 'owner': '0x1234' }) + + # Converts a Solidity value to an appropriate Ruby type. + # + # @param solidity_value [Coinbase::Client::SolidityValue] The Solidity value to convert + # @return [Object] The converted Ruby value + # @raise [ArgumentError] If an unsupported Solidity type is encountered + # + # This method handles the following Solidity types: + # - Integers (uint8, uint16, uint32, uint64, uint128, uint256, int8, int16, int32, int64, int128, int256) + # - Address + # - String + # - Bytes (including fixed-size byte arrays) + # - Boolean + # - Array + # - Tuple (converted to a Hash) + # + # For complex types like arrays and tuples, the method recursively converts nested values. + def convert_solidity_value(solidity_value) + return nil if solidity_value.nil? + + type = solidity_value.type + value = solidity_value.value + values = solidity_value.values + + case type + when 'uint8', 'uint16', 'uint32', 'uint64', 'uint128', 'uint256', + 'int8', 'int16', 'int32', 'int64', 'int128', 'int256' + value&.to_i + when 'address', 'string', /^bytes/ + value + when 'bool' + if value.is_a?(String) + value == 'true' + else + !value.nil? + end + when 'array' + values ? values.map { |v| convert_solidity_value(v) } : [] + when 'tuple' + if values + result = {} + values.each do |v| + raise ArgumentError, 'Error: Tuple value without a name' unless v.respond_to?(:name) + + result[v.name] = convert_solidity_value(v) + end + result + else + {} end - result else - {} + raise ArgumentError, "Unsupported Solidity type: #{type}" end - else - raise ArgumentError, "Unsupported Solidity type: #{type}" end - end - private_class_method :convert_solidity_value - def self.contract_events_api - Coinbase::Client::ContractEventsApi.new(Coinbase.configuration.api_client) - end - private_class_method :contract_events_api + def contract_events_api + Coinbase::Client::ContractEventsApi.new(Coinbase.configuration.api_client) + end - def self.smart_contracts_api - Coinbase::Client::SmartContractsApi.new(Coinbase.configuration.api_client) - end - private_class_method :smart_contracts_api - - def self.list_events_page( - network_id, - protocol_name, - contract_address, - contract_name, - event_name, - from_block_height, - to_block_height, - page - ) - contract_events_api.list_contract_events( - Coinbase.normalize_network(network_id), + def smart_contracts_api + Coinbase::Client::SmartContractsApi.new(Coinbase.configuration.api_client) + end + + def list_events_page( + network_id, protocol_name, contract_address, contract_name, event_name, from_block_height, to_block_height, - { next_page: page } + page ) + contract_events_api.list_contract_events( + Coinbase.normalize_network(network_id), + protocol_name, + contract_address, + contract_name, + event_name, + from_block_height, + to_block_height, + { next_page: page } + ) + end end - private_class_method :list_events_page # Returns a new SmartContract object. # @param model [Coinbase::Client::SmartContract] The underlying SmartContract object @@ -282,7 +318,7 @@ def contract_address @model.contract_address end - # Returns the address of the deployer of the SmartContract. + # Returns the address of the deployer of the SmartContract, if deployed via CDP. # @return [String] The deployer address def deployer_address @model.deployer_address @@ -294,7 +330,7 @@ def abi JSON.parse(@model.abi) end - # Returns the ID of the wallet that deployed the SmartContract. + # Returns the ID of the wallet that deployed the SmartContract, if deployed via CDP. # @return [String] The wallet ID def wallet_id @model.wallet_id @@ -306,22 +342,28 @@ def type @model.type end - # Returns the options of the SmartContract. + # Returns the options of the SmartContract, if deployed via CDP. # @return [Coinbase::Client::SmartContractOptions] The SmartContract options def options @model.options end - # Returns the transaction. - # @return [Coinbase::Transaction] The SmartContracy deployment transaction + # Returns whether the SmartContract is an externally registered contract or a CDP managed contract. + # @return [Boolean] Whether the SmartContract is external + def external? + @model.is_external + end + + # Returns the transaction, if deployed via CDP. + # @return [Coinbase::Transaction] The SmartContract deployment transaction def transaction - @transaction ||= Coinbase::Transaction.new(@model.transaction) + @transaction ||= @model.transaction.nil? ? nil : Coinbase::Transaction.new(@model.transaction) end - # Returns the status of the SmartContract. + # Returns the status of the SmartContract, if deployed via CDP. # @return [String] The status def status - transaction.status + transaction&.status end # Signs the SmartContract deployment transaction with the given key. @@ -332,6 +374,7 @@ def status # @raise [Coinbase::AlreadySignedError] If the SmartContract has already been signed def sign(key) raise unless key.is_a?(Eth::Key) + raise 'Cannot sign for an external smart contract' unless transaction transaction.sign(key) end @@ -340,6 +383,7 @@ def sign(key) # @return [SmartContract] The SmartContract object # @raise [Coinbase::TransactionNotSignedError] If the SmartContract has not been signed def deploy! + raise 'Cannot deploy an external smart contract' unless transaction raise TransactionNotSignedError unless transaction.signed? @model = Coinbase.call_api do @@ -360,14 +404,10 @@ def deploy! # @return [SmartContract] The most recent version of Smart Contract from the server def reload @model = Coinbase.call_api do - smart_contracts_api.get_smart_contract( - wallet_id, - deployer_address, - id - ) + smart_contracts_api.get_smart_contract(wallet_id, deployer_address, id) end - @transaction = Coinbase::Transaction.new(@model.transaction) + @transaction = Coinbase::Transaction.new(@model.transaction) if @model.transaction self end @@ -379,6 +419,8 @@ def reload # @return [SmartContract] The completed Smart Contract object # @raise [Timeout::Error] if the Contract Invocation takes longer than the given timeout def wait!(interval_seconds = 0.2, timeout_seconds = 20) + raise 'Cannot wait for an external smart contract' unless transaction + start_time = Time.now loop do @@ -387,8 +429,7 @@ def wait!(interval_seconds = 0.2, timeout_seconds = 20) return self if transaction.terminal_state? if Time.now - start_time > timeout_seconds - raise Timeout::Error, - 'SmartContract deployment timed out. Try waiting again.' + raise Timeout::Error, 'SmartContract deployment timed out. Try waiting again.' end self.sleep interval_seconds @@ -408,19 +449,22 @@ def inspect def to_s Coinbase.pretty_print_object( self.class, - network: network.id, - contract_address: contract_address, - deployer_address: deployer_address, - type: type, - status: status, - options: Coinbase.pretty_print_object('Options', **options) + **{ + network: network.id, + contract_address: contract_address, + type: type, + # Fields only present for CDP managed contracts. + status: status, + deployer_address: deployer_address, + options: options.nil? ? nil : Coinbase.pretty_print_object('Options', **options) + }.compact ) end private def smart_contracts_api - @smart_contracts_api ||= Coinbase::Client::SmartContractsApi.new(Coinbase.configuration.api_client) + @smart_contracts_api ||= self.class.smart_contracts_api end end end