Skip to content

Latest commit

 

History

History
730 lines (584 loc) · 25.3 KB

cap-0050.md

File metadata and controls

730 lines (584 loc) · 25.3 KB
CAP: 0050
Title: Smart Contract Interactions
Working Group:
    Owner: Jonathan Jove <@jonjove>
    Authors:
    Consulted: Leigh McCulloch <@leighmcculloch>, Tomer Weller <@tomerweller>
Status: Rejected
Created: 2022-05-10
Discussion: TBD
Protocol version: TBD

Simple Summary

Implement a simple way to interact with smart contracts.

Motivation

In Ethereum, contracts typically assume that they have the authority to take actions in the name of msg.sender. msg.sender is an address, meaning it can correspond to either an externally owned account or a contract. For example,

  • the ERC-20 transfer function sends funds from msg.sender to some other address
  • the ERC-20 approve function sets the allowance for spender to transfer funds from msg.sender
  • the ERC-20 transferFrom function sends funds from some address A to address B if msg.sender has sufficient allowance

In this model, an address "owns" something when it can become msg.sender. An externally owned account becomes msg.sender by submitting a transaction with a single signature from the appropriate public key. A contract becomes msg.sender by making a cross-contract call.

This approach has proven to be both simple and powerful. Ownership by an externally owned account is simply secured by a single secret key. But a smart wallet can be employed for more powerful ownership controls. For example, if a user wants to establish 2/3 multisignature control over assets then they can deploy a smart contract which receives an action and a set of signatures, verifies the signatures, checks that they satisfy the access control policy, then takes the specified action. This smart wallet becomes the literal owner of the assets, but control of the smart wallet is delegated to those with signing authority. As another example, if a user wants to enable key rotation then they can deploy a smart contract which receives an action and a signature, verifies the signature using the currently set signing key, then takes the specified action (the specified action includes the possibility of setting a new key).

This attitude of simplicity-by-default but flexibility-when-needed has been a foundational element of usability on Ethereum. Rather than reinvent the wheel, Stellar should adopt this paradigm to immediately maximize the usability of smart contracts.

Goals Alignment

This CAP is aligned with the following Stellar Network Goals:

  • The Stellar Network should make it easy for developers of Stellar projects to create highly usable products

Abstract

This proposal introduces InvokeContractTransaction and InvokeContractTransactionEnvelope which permit interacting with contracts. To maximize usability, a InvokeContractTransactionEnvelope takes an additional KeyedSignature which identifies who has invoked the contract (analogous to msg.sender in Ethereum). The proposal also introduces a new host function, get_invoker, so a contract can identify who has invoked the contract. The proposal also contains a number of extensions to support presigned messages, which would be relevant for an EIP-2612 style permit function, and the ability to run Wasm directly in a transaction.

Specification

XDR Changes

diff --git a/src/xdr/Stellar-ledger-entries.x b/src/xdr/Stellar-ledger-entries.x
index 377309f9..f420ad39 100644
--- a/src/xdr/Stellar-ledger-entries.x
+++ b/src/xdr/Stellar-ledger-entries.x
@@ -614,6 +614,8 @@ enum EnvelopeType
     ENVELOPE_TYPE_SCPVALUE = 4,
     ENVELOPE_TYPE_TX_FEE_BUMP = 5,
     ENVELOPE_TYPE_OP_ID = 6,
-    ENVELOPE_TYPE_POOL_REVOKE_OP_ID = 7
+    ENVELOPE_TYPE_POOL_REVOKE_OP_ID = 7,
+    ENVELOPE_TYPE_INVOKE_CONTRACT_TX = 8,
+    ENVELOPE_TYPE_SIGNED_INVOKE_CONTRACT_TX = 9
 };
 }
diff --git a/src/xdr/Stellar-transaction.x b/src/xdr/Stellar-transaction.x
index 3d9ee3ea..20c3eaae 100644
--- a/src/xdr/Stellar-transaction.x
+++ b/src/xdr/Stellar-transaction.x
@@ -20,6 +20,38 @@ struct DecoratedSignature
     Signature signature; // actual signature
 };
 
+enum KeyedSignatureType
+{
+    KEYED_SIGNATURE_NONE = 0,
+    KEYED_SIGNATURE_ED25519 = 1
+};
+
+union KeyedSignature switch (KeyedSignatureType type)
+{
+case KEYED_SIGNATURE_NONE:
+    void;
+case KEYED_SIGNATURE_TYPE_ED25519:
+    struct
+    {
+        uint256 publicKey;
+        Signature signature;
+    } ed25519;
+};
+
+enum AddressType
+{
+    ADDRESS_CONTRACT = 0,
+    ADDRESS_ED25519 = 1,
+};
+
+union Address switch (AddressType type)
+{
+case ADDRESS_CONTRACT:
+    int64 contractID;
+case ADDRESS_ED25519:
+    uint256 ed25519;
+};
+
 enum OperationType
 {
     CREATE_ACCOUNT = 0,
@@ -709,6 +741,68 @@ struct FeeBumpTransactionEnvelope
     DecoratedSignature signatures<20>;
 };
 
+struct ReadWriteSet
+{
+    // Keys that can be read but not written
+    LedgerKey readSet<>;
+
+    // Keys that can be read or written
+    LedgerKey writeSet<>;
+};
+
+struct InvokeContractTransaction
+{
+    // sourceAccount pays the fee and provides the sequence number
+    AccountID sourceAccount;
+
+    // seqNum to be consumed on sourceAccount
+    SequenceNumber seqNum;
+
+    // fee to be paid by sourceAccount
+    int64 fee;
+
+    // contractID identifies the contract to interact with
+    ContractID contractID;
+
+    // symbol to look up in contractID
+    SCSymbol symbol;
+
+    // list of parameters to pass to function
+    SCVal parameters<>;
+
+    // read-write set for the contract invocation
+    // note: don't specify sourceAccount unless the contract uses it
+    ReadWriteSet rwSet;
+};
+
+struct SignedInvokeContractTransaction
+{
+    // the tx to execute
+    InvokeContractTransaction tx;
+
+    // signatures over the SHA256 hash of TransactionSignaturePayload of
+    // InvokeContractTransaction
+    //
+    // used to authorize the invocation of tx.contractID at tx.symbol with
+    // parameters tx.parameters
+    //
+    // not necessarily related to tx.sourceAccount or any of its signers
+    KeyedSignature invokerSignature;
+};
+
+struct InvokeContractTransactionEnvelope
+{
+    // the tx to execute with invoker signature
+    SignedInvokeContractTransaction signedTx;
+
+    // signatures over the SHA256 hash of TransactionSignaturePayload of
+    // SignedInvokeContractTransaction
+    //
+    // used to authorize tx.sourceAccount to pay the fee and provide the
+    // sequence number
+    DecoratedSignature signatures<20>;
+};
+
 /* A TransactionEnvelope wraps a transaction with signatures. */
 union TransactionEnvelope switch (EnvelopeType type)
 {
@@ -718,6 +812,8 @@ case ENVELOPE_TYPE_TX:
     TransactionV1Envelope v1;
 case ENVELOPE_TYPE_TX_FEE_BUMP:
     FeeBumpTransactionEnvelope feeBump;
+case ENVELOPE_TYPE_INVOKE_CONTRACT_TX:
+    InvokeContractTransactionEnvelope invoke;
 };
 
 struct TransactionSignaturePayload
@@ -730,6 +826,10 @@ struct TransactionSignaturePayload
         Transaction tx;
     case ENVELOPE_TYPE_TX_FEE_BUMP:
         FeeBumpTransaction feeBump;
+    case ENVELOPE_TYPE_INVOKE_CONTRACT_TX:
+        InvokeContractTransaction invokeContract;
+    case ENVELOPE_TYPE_SIGNED_INVOKE_CONTRACT_TX:
+        SignedInvokeContractTransaction signedInvokeContract;
     }
     taggedTransaction;
 };

Semantics

No Changes to Overlay or Transaction Queue

InvokeContractTransactionEnvelope is a new type of TransactionEnvelope and utilizes the same replay protection rules, so all downstream concepts such as overlay messages and transaction queue work without modification.

Message Sender

Whenever a contract is executing, the contract can determine the identity of the caller by invoking the host function

fn get_invoker() -> Address;

If a transactioned was executed with invokerSignature of type KEYED_SIGNATURE_NONE then this will trap.

External Interaction with a Smart Contract

To interact with a contract, a user will submit an InvokeContractTransactionEnvelope env with the following properties:

  • env.tx.seqNum = seqNum(env.tx.sourceAccount) + 1
  • availableBalance(env.tx.sourceAccount) >= env.tx.fee
  • env.tx.contractID is the contract you want to interact with
  • env.tx.symbol is the function you want to call on the contract
  • env.tx.parameters are the parameters you want to pass to the function
  • env.signatures are valid signatures for signers of env.tx.sourceAccount with total weight exceeding lowThreshold(env.tx.sourceAccount)
  • env.invokerSignature.signature is a valid signature for env.invokerSignature.key

Immediately after the contract begins execution, a call to get_invoker() will return env.invokerSignature.key.

Cross-Contract Calls

To make a cross-contract call, a contract can invoke the host function

// parameters must be a vector
fn call(contractID: ContractID, symbol: SCSymbol, parameters: SCVal) -> SCVal;

which loads f = loadSymbol(contractID, symbol) then invokes f(parameters[0], ..., parameters[n]) where n is the length of parameters. Immediately after the contract begins execution, a call to get_invoker() will return the ContractID for the calling contract.

Design Rationale

Why is Invoker Signature Over the Entire Transaction?

It has been suggested that the invoker signature should only be over the invocation details (contract identifier, symbol, and parameters) rather than the entire transaction (source account, sequence number, fee, invocation details). The argument is that contracts should implement their own replay prevention mechanisms. This argument does not hold up to closer scruity.

We will examine some mechanisms for moving replay prevention into contracts to determine their advantages and disadvantages. It will show that the proposed mechanism of replay prevention at the transaction level is appropriate.

Token with Replay Prevention

We will try to develop a simple token standard along the lines of ERC-20 but supporting replay prevention. It is possible for an address to interact with a token an unbounded number of times, so the replay prevention mechanism must have an unbounded state space for each address.

One possible mechanism, entirely analogous to Stellar sequence numbers, is

  • for each address, the contract has an initial sequence number of 0,
  • an interaction is accepted if it specifies the current sequence number, and
  • the sequence number is incremented when an interaction is accepted.

Some of the functions in such an interface would look like

// Query sequence number
fn sequence_of(addr: Address) -> u64;

// ERC-20 transfer with a sequence number
fn transfer(seq_num: u64, to: Address, amount: u256) -> bool;

// ERC-20 transferFrom with a sequence number
fn transfer_from(seq_num: u64, from: Address, to: Address, amount: u256) -> bool;

// ... rest of ERC-20 interface ...

An externally owned account can invoke these functions by externally reading the sequence number, then crafting the appropriate transaction. A contract cannot do the same, because it cannot externally read the sequence number. Instead, the contract account must call sequenceOf as part of its execution.

While this approach does work, it has no obvious advantages over replay protection at the transaction level. The main disadvantage is the increased complexity of working with sequence numbers (a) in the token contract, and (b) in contracts that use the token contract.

Replay Prevention Proxy

We will try to develop a contract that provides replay prevention then forwards invocations to other contracts. It is possible for an address to interact with this contract an unbounded number of times, so the replay prevention mechanism must have an unbounded state space for each address.

One possible mechanism, entirely analogous to Stellar sequence numbers, is

  • for each address, the contract has an initial sequence number of 0,
  • an interaction is accepted if it specifies the current sequence number, and
  • the sequence number is incremented when an interaction is accepted.

The interface could look like

// Query sequence number
fn sequence_of(addr: Address) -> u64;

// Perform replay protection, then call symbol on contract with parameters
fn transaction(seq_num: u64, contract: Address, symbol: SCSymbol,
               parameters: SCVal, signature: Vec<u8>);

Another possible mechanism is

  • the contract stores a set of transaction hashes,
  • an interaction is accepted if the transaction hash is not in the set, and
  • the transaction hash is added to the set when an interaction is accepted.

The interface could look like

// Check if a transaction hash is in the set
fn has_executed(hash: Vec<u8>) -> u64;

// Perform replay protection, then call symbol on contract with parameters
fn transaction(contract: Address, symbol: SCSymbol, parameters: SCVal,
               signature: Vec<u8>);

In both cases, the called contract must check the signature. Why? Because get_invoker() will return the proxy address, not the invoker address specified at the transaction level. It follows that the called contract must be able to identify and authenticate the invoker through some other mechanism. We will show below (see "Why Shouldn't Contracts do Their Own Signature Checking?") that requiring signature verification at the contract level is not a good pattern in general.

One might argue that we could provide a host function that allows executing a cross-contract call in the name of the signer, meaning that immediately after control transfers to the called contract get_invoker() will return the signer rather than the caller. While this is possible, it doesn't solve the problem here. Why not? Because that call would itself require replay prevention!

Why Shouldn't Contracts do Their Own Signature Checking?

There are reasons why contracts should do their own signature checking for certain functions (see for example EIP-2612 permit). But building an entire interface around signatures is not a good idea. The problem is that signatures break the symmetry between externally owned accounts and contracts: a contract cannot sign whereas an externally owned account can.

We will try to develop a simple token standard along the lines of ERC-20 but requiring signatures on every invocation. Some of the functions in such an interface would look like

// ERC-20 balanceOf, no signatures required
fn balance_of(addr: Address) -> u256;

// ERC-20 transfer with a signature
fn transfer(to: Address, amount: u256, signature: Vec<u8>) -> bool;

// ERC-20 transferFrom with a signature
fn transfer_from(from: Address, to: Address, amount: u256,
                 signature: Vec<u8>) -> bool;

// ... rest of ERC-20 interface ...

We immediately run into a problem: contracts cannot call transfer or transfer_from because they cannot produce a signature. Its not possible for contracts to produce signatures because that would require knowledge of a cryptographic secret key, and that means the secret key would be publicly known.

One possible solution would be to support additional functions for contracts like

// ERC-20 transfer but only for contracts
fn contract_transfer(to: Address, amount: u256) -> bool;

// ERC-20 transferFrom but only for contracts
fn contract_transfer_from(from: Address, to: Address, amount: u256) -> bool;

that first check get_invoker() is a contract, then take actions in the name of that contract. But such a function would just be the classic implementation with additional restrictions.

A simpler solution is to always require an invoker signature. This preserves the ability to use get_invoker() for identity everywhere without concern for authorization. Contracts that desire an alternative signature scheme can always elect to ignore get_invoker(), meaning users can simply sign transactions with any arbitrary key.

When combined with the fact that the simpler approach also conveys replay prevention automatically, this approach should be preferred.

Contracts Can Implement Their Own Replay Protection and Signatures

The usage of an invoker signature coupled to the sequence number does not prevent contracts from implementing their own replay protection.

For example, EIP-2612 permit requires its own replay protection. That interface uses a sequence number scheme (see "Token with Replay Prevention") for that specific function. permit ignores the message sender and uses the signature instead.

If a contract wants to extend this mechanism generally, it can

  • ignore the invoker signature (transactions can include a signature from any key or use KEYED_SIGNATURE_NONE)
  • check signatures on every function (see "Why Shouldn't Contracts do Their Own Signature Checking?" for how one might handle this if contracts need to be supported)
  • perform replay protection on the data that was signed

Patterns like this can be used to implement complicated protocols like payment channels without reducing usability of more typical constructions.

The Necessity of KeyedSignature

Ethereum uses ECDSA, which makes it possible to recover the public key from a signature. This is not possible with EdDSA, so the public key must be provided. Unlike with DecoratedSignature, a hint is not sufficient as there is no fixed list to compare against.

Why does Envelope contain SignedInvoke contain Invoke?

An earlier design had invokerSignature parallel to signatures. The invokerSignature would then be unverified, permitting malicious actors to modify it and cause other accounts to pay for their invocation. This design eliminates that issue and also prevents the confused deputy problem because signatures and invokerSignature are over different messages.

Why support KEYED_SIGNATURE_NONE?

As described above, contracts that implement their own replay protection and signatures would ignore the invoker signature. Some contracts, like liquidity pools, can implement a swap function that doesn't depend on the invoker. In all of these cases, allowing KEYED_SIGNATURE_NONE saves 96 bytes (compared to ed25519).

Message Sender Does Not Acquire Signing Authority Over a Stellar Account

This proposal logically separates the notion of a Stellar account and an address which controls assets in a smart contract. Specifically, control over a Stellar account with a given public key is not conferred by control over assets in a smart contract with the same public key. For example, a Stellar account might have its master weight set to 0 while its public key grants control over assets in a smart contract. Allowing the message sender to control that corresponding Stellar account would, therefore, be a catastrophic security failure.

Incompatible with CAP-0048

This proposal is incompatible with CAP-0048. CAP-0048 depends on the ability to store a balance in a trustline, but in this proposal the invoker of a smart contract need not correspond with any Stellar account. As such, it is not necessarily possible to associate a trustline with the invoker of a smart contract.

Compatible with CAP-0049

This proposal is compatible with CAP-0049 subject to the following clarifications and modifications.

WrappedBalanceEntry and WrappedAllowanceEntry Are Owned by Contracts

Both types of ledger entry are owned by contracts, rather than the accounts to which they are related. Alternatively, we could simply use ContractDataEntry instead.

Wrapper Interface: wrap

This proposal removes wrap from the "Wrapper Interface" in CAP-0049. Instead, WrapAssetOp is introduced to perform a similar function. This change reflects the fact that a contract must not reduce the balance of a trustline (see "Message Sender Does Not Acquire Signing Authority Over a Stellar Account").

The definition of WrapAssetOp is

struct WrapAssetOp
{
    // address that owns the wrapped asset
    Address address;

    // asset to wrap
    Asset asset;

    // amount of asset to wrap
    int64 amount;
};

WrapAssetOp is invalid if

  • address.type() == ADDRESS_CONTRACT && address.contract().type() == ASSET_ADAPTOR
  • asset is invalid
  • amount <= 0

The behavior of WrapAssetOp is analogous to wrap in CAP-0049.

Wrapper Interface: unwrap

This proposal changes the signature of unwrap from the "Wrapper Interface" in CAP-0049. The new signature is

fn unwrap(destination: AccountID, value: uint256) -> bool;

which reflects the fact that the destination must now be specified as there is no direct relation between ownership in contracts and Stellar accounts.

Extension: Presigned Messages

While this proposal does not need to support presigned messages, it is instructive to consider how they fit into this model. The key requirements for presigned messages are

  • domain separation: a presigned message should not collide with any transaction or with any other presigned messages
  • usability: a presigned message should be easy to prepare and verify
  • performance: a presigned message should be efficient to prepare and verify

To satisfy the above requirements, we introduce the following XDR

enum EnvelopeType
{
    ENVELOPE_TYPE_TX_V0 = 0,
    ENVELOPE_TYPE_SCP = 1,
    ENVELOPE_TYPE_TX = 2,
    ENVELOPE_TYPE_AUTH = 3,
    ENVELOPE_TYPE_SCPVALUE = 4,
    ENVELOPE_TYPE_TX_FEE_BUMP = 5,
    ENVELOPE_TYPE_OP_ID = 6,
    ENVELOPE_TYPE_POOL_REVOKE_OP_ID = 7,
    ENVELOPE_TYPE_INVOKE_SMART_CONTRACT_TX = 8,
    ENVELOPE_TYPE_MESSAGE = 9
};

// Presigned message is an Ed25519 signature over the SHA256 hash of
// MessageSignaturePayload
struct MessageSignaturePayload
{
    Hash networkID;
    union switch (EnvelopeType type)
    {
    case ENVELOPE_TYPE_MESSAGE:
        SCVal message;
    }
    message;
};

This will not collide with any transaction because both are prefixed with the fixed-length networkID, followed by a 4-byte discriminant which will differ for presigned messages and transactions.

Although this can theoretically collide with other presigned messages, it is easy for an application to ensure this does not occur. For example, message could contain a vector with the following components

  • the verifying contract address
  • the domain separator
  • the actual message payload

In this case, presigned messages for different contracts would necessarily be different because the contract address would differ. The domain separator would prevent collisions between different signed messages for a single contract.

The host interface will have native support for operations on SCVal such as vectors, maps, and strings so it will be efficient to construct the messsage. The host interface can also support an efficient hash on SCVal.

Extension: More Powerful Contract Interactions

It could be desirable to allow a transaction to do more than just invoke a specific function on a contract. This could be permitted by adding the following XDR

struct RunWasmTransaction
{
    // sourceAccount pays the fee and provides the sequence number
    AccountID sourceAccount;

    // seqNum to be consumed on sourceAccount
    SequenceNumber seqNum;

    // fee to be paid by sourceAccount
    int64 fee;

    // Wasm bytecode
    opaque wasm<>;

    // symbol to look up in wasm
    SCSymbol symbol;

    // list of parameters to pass to function
    SCVal parameters<>;
};

struct RunWasmTransactionEnvelope
{
    // the tx to execute
    RunWasmTransaction tx;

    // signatures over the SHA256 hash of TransactionSignaturePayload
    //
    // used to authorize tx.sourceAccount to pay the fee and provide the
    // sequence number
    DecoratedSignature signatures<20>;

    // signatures over the SHA256 hash of TransactionSignaturePayload
    //
    // used to authorize the invocation of tx.wasm
    //
    // not necessarily related to tx.sourceAccount or any of its signers
    KeyedSignature invokerSignature;
};

enum EnvelopeType
{
    ENVELOPE_TYPE_TX_V0 = 0,
    ENVELOPE_TYPE_SCP = 1,
    ENVELOPE_TYPE_TX = 2,
    ENVELOPE_TYPE_AUTH = 3,
    ENVELOPE_TYPE_SCPVALUE = 4,
    ENVELOPE_TYPE_TX_FEE_BUMP = 5,
    ENVELOPE_TYPE_OP_ID = 6,
    ENVELOPE_TYPE_POOL_REVOKE_OP_ID = 7,
    ENVELOPE_TYPE_INVOKE_SMART_CONTRACT_TX = 8,
    ENVELOPE_TYPE_RUN_WASM_TX = 9
};

union TransactionEnvelope switch (EnvelopeType type)
{
case ENVELOPE_TYPE_TX_V0:
    TransactionV0Envelope v0;
case ENVELOPE_TYPE_TX:
    TransactionV1Envelope v1;
case ENVELOPE_TYPE_TX_FEE_BUMP:
    FeeBumpTransactionEnvelope feeBump;
case ENVELOPE_TYPE_INVOKE_SMART_CONTRACT_TX:
    InvokeContractTransactionEnvelope invoke;
case ENVELOPE_TYPE_RUN_WASM_TX:
    RunWasmTransactionEnvelope runWasm;
};

To run arbitrary Wasm, a user will submit a RunWasmTransactionEnvelope env with the following properties:

  • env.tx.seqNum = seqNum(env.tx.sourceAccount) + 1
  • availableBalance(env.tx.sourceAccount) >= env.tx.fee
  • env.tx.wasm is the Wasm you want to run
  • env.tx.symbol is the function you want to call in the Wasm module
  • env.tx.parameters are the parameters you want to pass to the function
  • env.signatures are valid signatures for signers of env.tx.sourceAccount with total weight exceeding lowThreshold(env.tx.sourceAccount)
  • env.invokerSignature.signature is a valid signature for env.invokerSignature.key

Immediately after the Wasm begins execution, a call to get_invoker() will return env.invokerSignature.key.

Protocol Upgrade Transition

Backwards Incompatibilities

This proposal is completely backwards compatible.

Resource Utilization

This proposal increases the maximum number of signatures permitted in a transaction, which might slightly increase cost of verification.

Test Cases

None yet.

Implementation

None yet.