Skip to content

Commit

Permalink
Support invoking smart contracts from MPC Wallets (#176)
Browse files Browse the repository at this point in the history
### What changed? Why?
This adds support for invoking smart contracts from MPC wallets
and from developer-managed wallets.

```
abi =  abi = [
  {
    inputs: [{internalType: 'address', name: 'recipient', type: 'address'}],
    name: 'mint',
    outputs: [{internalType: 'uint256', name: '', type: 'uint256'}],
    stateMutability: 'payable',
    type: 'function'
  }
]

invocation = wallet.invoke_contract(contract_address: '0xa82aB8504fDeb2dADAa3B4F075E967BbE35065b9', abi: abi, method: 'mint', args: { recipient: '0x475d41de7A81298Ba263184996800CBcaAD73C0b' })

invocation.wait!
```

#### Qualified Impact
<!-- Please evaluate what components could be affected and what the
impact would be if there was an
error. How would this error be resolved, e.g. rollback a deploy, push a
new fix, disable a feature
flag, etc... -->

This adds a new feature and should not impact existing features
  • Loading branch information
alex-stone authored Sep 5, 2024
1 parent bc1ca77 commit 33bdaf3
Show file tree
Hide file tree
Showing 11 changed files with 866 additions and 8 deletions.
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

Added
- Add support for listing address transactions.
## [0.3.0] - 2024-09-05

### Added

- Add support for listing address transactions.
- Add support for creating arbitrary payload signatures.
- Add support for invoking Smart Contracts using MPC and Developer-managed Wallets.

## [0.2.0] - 2024-08-28

Added
### Added
- USDC Faucet support on Base-Sepolia
- Doc updates for staking

Expand Down
3 changes: 2 additions & 1 deletion lib/coinbase.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
require_relative 'coinbase/client'
require_relative 'coinbase/constants'
require_relative 'coinbase/contract_event'
require_relative 'coinbase/contract_invocation'
require_relative 'coinbase/destination'
require_relative 'coinbase/errors'
require_relative 'coinbase/faucet_transaction'
require_relative 'coinbase/middleware'
require_relative 'coinbase/network'
require_relative 'coinbase/pagination'
require_relative 'coinbase/payload_signature'
require_relative 'coinbase/trade'
require_relative 'coinbase/transfer'
require_relative 'coinbase/transaction'
Expand All @@ -31,7 +33,6 @@
require_relative 'coinbase/validator'
require_relative 'coinbase/version'
require_relative 'coinbase/webhook'
require_relative 'coinbase/payload_signature'
require 'json'

# The Coinbase SDK.
Expand Down
27 changes: 27 additions & 0 deletions lib/coinbase/address/wallet_address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,33 @@ def trade(amount, from_asset_id, to_asset_id)
trade
end

# Invokes a contract method on the specified contract address, with the given ABI and arguments.
# @param contract_address [String] The address of the contract to invoke.
# @param abi [Array<Hash>] The ABI of the contract to invoke.
# @param method [String] The method to invoke on the contract.
# @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 [Coinbase::ContractInvocation] The contract invocation object.
def invoke_contract(contract_address:, abi:, method:, args:)
ensure_can_sign!

invocation = ContractInvocation.create(
address_id: id,
wallet_id: wallet_id,
contract_address: contract_address,
abi: abi,
method: method,
args: args
)

# If a server signer is managing keys, it will sign and broadcast the underlying transaction out of band.
return invocation if Coinbase.use_server_signer?

invocation.sign(@key)
invocation.broadcast!
invocation
end

# Signs the given unsigned payload.
# @param unsigned_payload [String] The hex-encoded hashed unsigned payload for the Address to sign.
# @return [Coinbase::PayloadSignature] The payload signature
Expand Down
43 changes: 43 additions & 0 deletions lib/coinbase/client/models/feature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
=begin
#Coinbase Platform API
#This is the OpenAPI 3.0 specification for the Coinbase Platform APIs, used in conjunction with the Coinbase Platform SDKs.
The version of the OpenAPI document: 0.0.1-alpha
Contact: [email protected]
Generated by: https://openapi-generator.tech
Generator version: 7.7.0
=end

require 'date'
require 'time'

module Coinbase::Client
class Feature
TRANSFER = "transfer".freeze
TRADE = "trade".freeze
FAUCET = "faucet".freeze
SERVER_SIGNER = "server_signer".freeze
UNKNOWN_DEFAULT_OPEN_API = "unknown_default_open_api".freeze

def self.all_vars
@all_vars ||= [TRANSFER, TRADE, FAUCET, SERVER_SIGNER, UNKNOWN_DEFAULT_OPEN_API].freeze
end

# Builds the enum from string
# @param [String] The enum value in the form of the string
# @return [String] The enum value
def self.build_from_hash(value)
new.build_from_hash(value)
end

# Builds the enum from string
# @param [String] The enum value in the form of the string
# @return [String] The enum value
def build_from_hash(value)
return value if Feature.all_vars.include?(value)
raise "Invalid ENUM value #{value} for class #Feature"
end
end
end
234 changes: 234 additions & 0 deletions lib/coinbase/contract_invocation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# frozen_string_literal: true

module Coinbase
# A representation of a Contract Invocation.
class ContractInvocation
class << self
# Creates a new ContractInvocation object.
# @param address_id [String] The Address ID of the signing Address
# @param wallet_id [String] The Wallet ID associated with the signing Address
# @param contract_address [String] The contract address
# @param abi [Array<Hash>] The contract ABI
# @param method [String] The contract method
# @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 [ContractInvocation] The new Contract Invocation object
# @raise [Coinbase::ApiError] If the request to create the Contract Invocation fails
def create(
address_id:,
wallet_id:,
contract_address:,
abi:,
method:,
args: {}
)
model = Coinbase.call_api do
contract_invocation_api.create_contract_invocation(
wallet_id,
address_id,
contract_address: contract_address,
abi: abi.to_json,
method: method,
args: args.to_json
)
end

new(model)
end

# Enumerates the payload signatures for a given address belonging to a wallet.
# The result is an enumerator that lazily fetches from the server, and can be iterated over,
# converted an array, etc...
# @return [Enumerable<Coinbase::ContractInvocation>] Enumerator that returns payload signatures
def list(wallet_id:, address_id:)
Coinbase::Pagination.enumerate(
->(page) { fetch_page(wallet_id, address_id, page) }
) do |contract_invocation|
new(contract_invocation)
end
end

private

def contract_invocation_api
Coinbase::Client::ContractInvocationsApi.new(Coinbase.configuration.api_client)
end

def fetch_page(wallet_id, address_id, page)
contract_invocation_api.list_contract_invocations(
wallet_id,
address_id,
limit: DEFAULT_PAGE_LIMIT,
page: page
)
end
end

# Returns a new ContractInvocation object. Do not use this method directly.
# Instead use Coinbase::ContractInvocation.create.
# @param model [Coinbase::Client::ContractInvocation] The underlying Contract Invocation obejct
def initialize(model)
raise unless model.is_a?(Coinbase::Client::ContractInvocation)

@model = model
end

# Returns the Contract Invocation ID.
# @return [String] The Contract Invocation ID
def id
@model.contract_invocation_id
end

# Returns the Wallet ID of the Contract Invocation.
# @return [String] The Wallet ID
def wallet_id
@model.wallet_id
end

# Returns the Address ID of the Contract Invocation.
# @return [String] The Address ID
def address_id
@model.address_id
end

# Returns the Network of the Contract Invocation.
# @return [Coinbase::Network] The Network
def network
@network ||= Coinbase::Network.from_id(@model.network_id)
end

# Returns the Contract Address of the Contract Invocation.
# @return [String] The Contract Address
def contract_address
@model.contract_address
end

# Returns the ABI of the Contract Invocation.
# @return [Array<Hash>] The ABI
def abi
JSON.parse(@model.abi)
end

# Returns the method of the Contract Invocation.
# @return [String] The method
def method
@model.method
end

# Returns the arguments of the Contract Invocation.
# @return [Hash] The arguments
def args
JSON.parse(@model.args).transform_keys(&:to_sym)
end

# Returns the transaction.
# @return [Coinbase::Transaction] The Transfer transaction
def transaction
@transaction ||= Coinbase::Transaction.new(@model.transaction)
end

# Returns the status of the Contract Invocation.
# @return [String] The status
def status
transaction.status
end

# Signs the Contract Invocation transaction with the given key.
# This is required before broadcasting the Contract Invocation when not using
# a Server-Signer.
# @param key [Eth::Key] The key to sign the ContractInvocation with
# @raise [RuntimeError] If the key is not an Eth::Key
# @return [ContractInvocation] The ContractInvocation object
def sign(key)
raise unless key.is_a?(Eth::Key)

transaction.sign(key)

self
end

# Broadcasts the ContractInvocation to the Network.
# @raise [RuntimeError] If the ContractInvocation is not signed
# @return [ContractInvocation] The ContractInvocation object
def broadcast!
raise TransactionNotSignedError unless transaction.signed?

@model = Coinbase.call_api do
contract_invocation_api.broadcast_contract_invocation(
wallet_id,
address_id,
id,
{ signed_payload: transaction.signature }
)
end

@transaction = Coinbase::Transaction.new(@model.transaction)

self
end

# # Reload reloads the Contract Invocation model with the latest version from the server side.
# @return [ContractInvocation] The most recent version of Contract Invocation from the server
def reload
@model = Coinbase.call_api do
contract_invocation_api.get_contract_invocation(wallet_id, address_id, id)
end

@transaction = Coinbase::Transaction.new(@model.transaction)

self
end

# Waits until the Contract Invocation is signed or failed by polling the server at the given interval. Raises a
# Timeout::Error if the Contract Invocation takes longer than the given timeout.
# @param interval_seconds [Integer] The interval at which to poll the server, in seconds
# @param timeout_seconds [Integer] The maximum amount of time to wait for the Contract Invocation to be signed,
# in seconds.
# @return [ContractInvocation] The completed Contract Invocation object
def wait!(interval_seconds = 0.2, timeout_seconds = 20)
start_time = Time.now

loop do
reload

return self if transaction.terminal_state?

raise Timeout::Error, 'Contract Invocation timed out' if Time.now - start_time > timeout_seconds

self.sleep interval_seconds
end

self
end

# Returns a String representation of the Contract Invocation.
# @return [String] a String representation of the Contract Invocation
def to_s
Coinbase.pretty_print_object(
self.class,
id: id,
wallet_id: wallet_id,
address_id: address_id,
network_id: network.id,
status: status,
abi: abi.to_json,
method: method,
args: args.to_json,
transaction_hash: transaction.transaction_hash,
transaction_link: transaction.transaction_link
)
end

# Same as to_s.
# @return [String] a String representation of the ContractInvocation
def inspect
to_s
end

private

def contract_invocation_api
@contract_invocation_api ||= Coinbase::Client::ContractInvocationsApi.new(Coinbase.configuration.api_client)
end
end
end
2 changes: 1 addition & 1 deletion lib/coinbase/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Coinbase
VERSION = '0.2.0'
VERSION = '0.3.0'
end
7 changes: 6 additions & 1 deletion lib/coinbase/wallet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,13 @@ def initialize(model, seed: nil)
# @param unsigned_payload [String] The hex-encoded hashed unsigned payload for the Address to sign.
# @return [Coinbase::PayloadSignature] The payload signature

# @!method invoke_contract
# Invokes a contract with the given ABI, method, and arguments.
# @param abi [Array<Hash>] The ABI of the contract
#

def_delegators :default_address, :transfer, :trade, :faucet, :stake, :unstake, :claim_stake, :staking_balances,
:stakeable_balance, :unstakeable_balance, :claimable_balance, :sign_payload
:stakeable_balance, :unstakeable_balance, :claimable_balance, :sign_payload, :invoke_contract

# Returns the addresses belonging to the Wallet.
# @return [Array<Coinbase::WalletAddress>] The addresses belonging to the Wallet
Expand Down
Loading

0 comments on commit 33bdaf3

Please sign in to comment.