From 13220f615d0daaa4f489422189fb38dbfbd66096 Mon Sep 17 00:00:00 2001 From: Alex Stone Date: Wed, 11 Dec 2024 08:57:54 -0800 Subject: [PATCH] feat: 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! --- CHANGELOG.md | 2 + lib/coinbase/errors.rb | 8 + lib/coinbase/smart_contract.rb | 541 ++++++++++++---------- spec/factories/smart_contract.rb | 20 +- spec/unit/coinbase/smart_contract_spec.rb | 244 +++++++++- 5 files changed, 554 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9643f20..9529cd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased * Add support for fetching address reputation. +* Add support for registering, updating, and listing smart contracts that are + deployed external to CDP. ## [0.12.0] - Skipped diff --git a/lib/coinbase/errors.rb b/lib/coinbase/errors.rb index d1b6562a..44e431c4 100644 --- a/lib/coinbase/errors.rb +++ b/lib/coinbase/errors.rb @@ -84,6 +84,14 @@ def initialize(msg = 'Transaction must be signed') end end + # An error raised when attempting to manage an external contract on-chain, + # e.g. sign the deployment tx, deploy the contract, etc... + class ManageExternalContractError < StandardError + def initialize(action = 'manage') + super("Cannot #{action} external smart contract") + end + end + # An error raised when an address attempts to sign a transaction without a private key. class AddressCannotSignError < StandardError def initialize(msg = 'Address cannot sign transaction without private key loaded') diff --git a/lib/coinbase/smart_contract.rb b/lib/coinbase/smart_contract.rb index bd7ae5c7..6e9a8c36 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,8 +318,9 @@ def contract_address @model.contract_address end - # Returns the address of the deployer of the SmartContract. - # @return [String] The deployer address + # Returns the address of the deployer of the SmartContract, if deployed via CDP. + # Returns nil for externally registered contracts. + # @return [String, nil] The deployer address def deployer_address @model.deployer_address end @@ -294,7 +331,8 @@ 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. + # Returns nil for externally registered contracts. # @return [String] The wallet ID def wallet_id @model.wallet_id @@ -306,22 +344,29 @@ def type @model.type end - # Returns the options of the SmartContract. - # @return [Coinbase::Client::SmartContractOptions] The SmartContract options + # Returns the options of the SmartContract, if deployed via CDP. + # Returns nil for externally registered contracts. + # @return [Coinbase::Client::SmartContractOptions, nil] 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. @@ -329,8 +374,10 @@ def status # @param key [Eth::Key] The key to sign the SmartContract with # @return [SmartContract] The SmartContract object # @raise [RuntimeError] If the key is not an Eth::Key + # @raise [RuntimeError] If the SmartContract is external # @raise [Coinbase::AlreadySignedError] If the SmartContract has already been signed def sign(key) + raise ManageExternalContractError, 'sign' if external? raise unless key.is_a?(Eth::Key) transaction.sign(key) @@ -339,7 +386,9 @@ def sign(key) # Deploys the signed SmartContract to the blockchain. # @return [SmartContract] The SmartContract object # @raise [Coinbase::TransactionNotSignedError] If the SmartContract has not been signed + # @raise [RuntimeError] If the SmartContract is external def deploy! + raise ManageExternalContractError, 'deploy' if external? raise TransactionNotSignedError unless transaction.signed? @model = Coinbase.call_api do @@ -359,15 +408,13 @@ def deploy! # Reloads the Smart Contract model with the latest version from the server side. # @return [SmartContract] The most recent version of Smart Contract from the server def reload + raise ManageExternalContractError, 'reload' if external? + @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 +426,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 ManageExternalContractError, 'wait!' if external? + start_time = Time.now loop do @@ -387,8 +436,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,12 +456,15 @@ 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 diff --git a/spec/factories/smart_contract.rb b/spec/factories/smart_contract.rb index cdbed341..147d1d3a 100644 --- a/spec/factories/smart_contract.rb +++ b/spec/factories/smart_contract.rb @@ -13,6 +13,7 @@ base_uri { 'https://test.com' } end + is_external { false } deployer_address { key.address.to_s } wallet_id { SecureRandom.uuid } smart_contract_id { SecureRandom.uuid } @@ -36,6 +37,7 @@ end trait :token do + type { Coinbase::Client::SmartContractType::ERC20 } options do Coinbase::Client::TokenContractOptions.new( name: name, @@ -46,6 +48,7 @@ end trait :nft do + type { Coinbase::Client::SmartContractType::ERC721 } options do Coinbase::Client::NFTContractOptions.new( name: name, @@ -56,6 +59,7 @@ end trait :multi_token do + type { Coinbase::Client::SmartContractType::ERC1155 } options do Coinbase::Client::MultiTokenContractOptions.new( uri: 'https://example.com/token/{id}.json' @@ -63,6 +67,14 @@ end end + trait :external do + is_external { true } + deployer_address { nil } + wallet_id { nil } + type { Coinbase::Client::SmartContractType::CUSTOM } + options { nil } + end + NETWORK_TRAITS.each do |network| trait network do network_id { Coinbase.normalize_network(network) } @@ -76,7 +88,9 @@ end after(:build) do |invocation, transients| - invocation.transaction = build(:transaction_model, transients.status, key: transients.key) + unless invocation.is_external + invocation.transaction = build(:transaction_model, transients.status, key: transients.key) + end end end @@ -107,6 +121,10 @@ type { :multi_token } end + trait :external do + type { :external } + end + TX_TRAITS.each do |status| trait status do status { status } diff --git a/spec/unit/coinbase/smart_contract_spec.rb b/spec/unit/coinbase/smart_contract_spec.rb index 49174a89..f94c3ac5 100644 --- a/spec/unit/coinbase/smart_contract_spec.rb +++ b/spec/unit/coinbase/smart_contract_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true describe Coinbase::SmartContract do - subject(:smart_contract) do - described_class.new(model) - end + subject(:smart_contract) { described_class.new(model) } let(:network_id) { :base_sepolia } let(:network) { build(:network, network_id) } @@ -23,6 +21,7 @@ total_supply: total_supply ) end + let(:external_model) { build(:smart_contract_model, network_id, :external) } before do allow(Coinbase::Client::SmartContractsApi).to receive(:new).and_return(smart_contracts_api) @@ -95,13 +94,16 @@ end let(:nft_contract_model) do - build(:smart_contract_model, network_id, - type: Coinbase::Client::SmartContractType::ERC721, - options: Coinbase::Client::NFTContractOptions.new( - name: nft_name, - symbol: nft_symbol, - base_uri: base_uri - )) + build( + :smart_contract_model, + network_id, + type: Coinbase::Client::SmartContractType::ERC721, + options: Coinbase::Client::NFTContractOptions.new( + name: nft_name, + symbol: nft_symbol, + base_uri: base_uri + ) + ) end before do @@ -160,11 +162,14 @@ end let(:multi_token_contract_model) do - build(:smart_contract_model, network_id, - type: Coinbase::Client::SmartContractType::ERC1155, - options: Coinbase::Client::MultiTokenContractOptions.new( - uri: uri - )) + build( + :smart_contract_model, + network_id, + type: Coinbase::Client::SmartContractType::ERC1155, + options: Coinbase::Client::MultiTokenContractOptions.new( + uri: uri + ) + ) end before do @@ -194,6 +199,135 @@ end end + describe '.register' do + subject(:smart_contract) do + described_class.register( + network: network, + contract_address: contract_address, + name: contract_name, + abi: request_abi + ) + end + + let(:contract_name) { 'TestContract' } + let(:contract_address) { '0x1234567890123456789012345678901234567890' } + let(:abi) { [{ 'name' => 'testMethod', 'inputs' => [], 'outputs' => [] }] } + let(:request_abi) { abi } + let(:register_smart_contract_request) do + { + contract_name: contract_name, + abi: abi.to_json + } + end + + let(:external_model) do + build( + :smart_contract_model, + network_id, + :external, + abi: abi.to_json, + contract_address: contract_address, + contract_name: contract_name + ) + end + + before do + allow(smart_contracts_api) + .to receive(:register_smart_contract) + .with( + network.normalized_id, + contract_address, + register_smart_contract_request: register_smart_contract_request + ).and_return(external_model) + end + + it 'creates a new SmartContract' do + expect(smart_contract).to be_a(described_class) + end + + it 'sets the smart_contract properties' do + expect(smart_contract.id).to eq(external_model.smart_contract_id) + end + + it 'sets the ABI' do + expect(smart_contract.abi).to eq(abi) + end + + it 'calls register_smart_contract with correct parameters' do + smart_contract + expect(smart_contracts_api).to have_received(:register_smart_contract) + .with( + network.normalized_id, + contract_address, + register_smart_contract_request: register_smart_contract_request + ) + end + + context 'when the ABI is passed as a JSON-encoded string' do + let(:request_abi) { abi.to_json } + + it 'calls register_smart_contract with the ABI as a JSON-encoded string' do + smart_contract + + expect(smart_contracts_api) + .to have_received(:register_smart_contract) + .with( + network.normalized_id, + contract_address, + register_smart_contract_request: register_smart_contract_request + ) + end + end + + context 'when the ABI is not a valid JSON-encoded string' do + let(:abi) { 'invalid' } + + it 'raises an error' do + expect { smart_contract }.to raise_error(ArgumentError) + end + end + + context 'when the provided network is a symbol' do + let(:network_id) { :ethereum_mainnet } + + it 'calls register_smart_contract with the normalized network ID' do + smart_contract + + expect(smart_contracts_api) + .to have_received(:register_smart_contract) + .with( + 'ethereum-mainnet', + contract_address, + register_smart_contract_request: register_smart_contract_request + ) + end + end + + context 'when the network is omitted' do + subject(:smart_contract) do + described_class.register( + contract_address: contract_address, + name: contract_name, + abi: abi + ) + end + + let(:network) { default_network } + + it 'calls register_smart_contract with the default network' do + smart_contract + + expect(smart_contracts_api) + .to have_received(:register_smart_contract) + .with( + default_network.normalized_id, + contract_address, + register_smart_contract_request: register_smart_contract_request + ) + end + end + end + describe '.read' do subject(:result) do described_class.read( @@ -706,12 +840,28 @@ def build_nested_solidity_value(hash) it 'returns the wallet ID' do expect(smart_contract.wallet_id).to eq(wallet_id) end + + context 'when the smart contract is external' do + subject(:smart_contract) { described_class.new(external_model) } + + it 'returns nil' do + expect(smart_contract.wallet_id).to be_nil + end + end end describe '#deployer_address' do it 'returns the deployer address' do expect(smart_contract.deployer_address).to eq(model.deployer_address) end + + context 'when the smart contract is external' do + subject(:smart_contract) { described_class.new(external_model) } + + it 'returns nil' do + expect(smart_contract.deployer_address).to be_nil + end + end end describe '#type' do @@ -724,6 +874,14 @@ def build_nested_solidity_value(hash) it 'returns the smart contract options' do expect(smart_contract.options).to eq(model.options) end + + context 'when the smart contract is external' do + subject(:smart_contract) { described_class.new(external_model) } + + it 'returns nil' do + expect(smart_contract.options).to be_nil + end + end end describe '#transaction' do @@ -734,6 +892,14 @@ def build_nested_solidity_value(hash) it 'sets the from_address_id' do expect(smart_contract.transaction.from_address_id).to eq(address_id) end + + context 'when the smart contract is external' do + subject(:smart_contract) { described_class.new(external_model) } + + it 'returns nil' do + expect(smart_contract.transaction).to be_nil + end + end end describe '#sign' do @@ -763,6 +929,16 @@ def build_nested_solidity_value(hash) expect { smart_contract.sign('invalid key') }.to raise_error(RuntimeError) end end + + context 'when the smart contract is external' do + subject(:smart_contract) { build(:smart_contract, :external) } + + let(:key) { Eth::Key.new } + + it 'raises an error' do + expect { smart_contract.sign(key) }.to raise_error(Coinbase::ManageExternalContractError) + end + end end describe '#deploy!' do @@ -824,6 +1000,14 @@ def build_nested_solidity_value(hash) expect { deployed_smart_contract }.to raise_error(Coinbase::TransactionNotSignedError) end end + + context 'when the smart contract is external' do + subject(:smart_contract) { build(:smart_contract, :external) } + + it 'raises an error' do + expect { smart_contract.deploy! }.to raise_error(Coinbase::ManageExternalContractError) + end + end end describe '#reload' do @@ -839,6 +1023,14 @@ def build_nested_solidity_value(hash) it 'updates the smart contract transaction' do expect(smart_contract.reload.transaction.status).to eq(Coinbase::Transaction::Status::COMPLETE) end + + context 'when the smart contract is external' do + subject(:smart_contract) { build(:smart_contract, :external) } + + it 'raises an error' do + expect { smart_contract.reload }.to raise_error(Coinbase::ManageExternalContractError) + end + end end describe '#wait!' do @@ -876,6 +1068,16 @@ def build_nested_solidity_value(hash) end.to raise_error(Timeout::Error, 'SmartContract deployment timed out. Try waiting again.') end end + + context 'when the smart contract is external' do + subject(:smart_contract) { build(:smart_contract, :external) } + + let(:updated_model) { nil } + + it 'raises an error' do + expect { smart_contract.wait! }.to raise_error(Coinbase::ManageExternalContractError) + end + end end describe '#inspect' do @@ -902,5 +1104,17 @@ def build_nested_solidity_value(hash) expect(smart_contract.inspect).to include('broadcast') end end + + context 'when the smart contract is external' do + subject(:smart_contract) { build(:smart_contract, :external) } + + it 'includes the external smart contract details' do + expect(smart_contract.inspect).to include( + smart_contract.contract_address, + Coinbase.to_sym(network_id).to_s, + 'custom' + ) + end + end end end