Reentrant Contracts

Suppose I have the following contract, which responds to two commands. The first command (msg.data[0] == 1) sets a storage cell to a magic number, and then CALLs out to another contract, whose address is determined by another msg.data parameter. After the CALL finishes, the contract asserts that the magic number is still set correctly (or else it suicides). The second command (msg.data[0] == 2) resets the storage cell.

// Reentrant contract
command = msg.data[0]
if command == 1:
contract.storage[0] = 99
contractToCall = msg.data[1]
params = [2]
msg(200, contractToCall, 0, params, 1)

if contract.storage[0] != 99:
suicide()

if command == 2: // option 2
contract.storage[0] = -1
// End of contract


This contract can be tricked into calling itself, in which case the assumption is violated (the memory cell is not the same *after* the CALL as it was *before*) and therefore it kills itself. A pyethereum test script illustrating this can be found here: https://gist.github.com/amiller/cdc42df919a9b1dcf7df#file-concurrency_example-py


One way to guard against this is to use a mutex, that prevents a critical section (the entire contract) from being entered a second time while it's already in progress.

// Locked contract

if contract.storage[100] == 1:
stop # Don't enter a contract that's locked
contract.storage[100] = 1 # Lock the contract

command = msg.data[0]
if command == 1:
contract.storage[0] = 99
contractToCall = msg.data[1]
params = [2]
msg(200, contractToCall, 0, params, 1)

if contract.storage[0] != 99:
suicide()

if command == 2: // option 2
contract.storage[0] = -1

contract.storage[100] = 0 # Unlock
// End of contract

Again, an example is here: https://gist.github.com/amiller/cdc42df919a9b1dcf7df#file-locking_example-py
The problem is, since call/msg are *synchronous* and in general Ethereum contracts use a single thread of execution, there is no way for the thread to "suspend" when it reaches a locked section. In this case, the transaction just halts.

What we could do in a multithreaded environment is to spawn a new thread to perform the message call. This changes the semantics of the program - the call probably doesn't finish before the initiating transaction completes, and it depends on the application whether this is acceptable or not. What you are guaranteed though is that (assuming your OS scheduler is fair) the call will eventually be performed, exactly once.

However, we don't have threads in Ethereum contracts. Instead of actually making the message call from within the contract, you could just delegate this out of band to a trusted external party. Hopefully the external party gets around to sending a message to the contract you want, with the parameters you intended, and with sufficient gas. The external party would effectively replace the "scheduler" from the multithreading operating system described above.

But there are a few problems with this. External parties can't be relied on to be fair. They might deliver the messages you wanted in the wrong order, with an arbitrary delay, or selectively drop *your* messages while delivering everyone else's.


We can do better. The solution I'm about to describe will still rely on an external party - however, the external party's role will be minimal, and in fact the external party will have no control over fairness or delivery order.

The solution is to use a separate contract that acts as a message queue gadget. The message queue supports two operations.

The first, "Post", allows you to submit a new message to be delivered later. In order to "Post", you send a message to this gadget that indicates a contract address, message inputs, and numbers describing the gas and value you want to send. You also have to send this gadget enough *value* to pay for the gas and value transfer for the enqueued message call. All this data is appended to the message queue gadget's storage.

The second operation, "Dispatch", delivers a single message, from the *front* of the queue. There are a few clever things going on here. Whoever creates the "Dispatch" transaction has to provide enough gas to perform the intended call. Whoever made the "Post" message had to provide enough *value* to cover this, but that value can't be converted into gas while the contract is running. Instead, the deposited value is used to *refund* the sender of the "Dispatch" message. The mutex mechanism described earlier is used again here to guarantee that the message queue is sound - each enqueued message is delivered at most once, and no message is skipped.

So, an external party has to have enough Ether to pay for the dispatch, but they're guaranteed to get compensated. The external party cannot control the order of message delivery - messages are deliver in *exactly* the order they are posted. A single instance of this contract should suffice for everyone who wants to use it - if *everyone* uses the same message queue, then it's impossible to selectively deliver one user's messages and not another's. It's possible that no external party might ever submit dispatch commands, but everyone's messages are in the same boat, for better or worse.

// Message Queue Contract
init:
contract.storage[0] = 0 # Starts unlocked
contract.storage[1] = 3 # Queue head
contract.storage[2] = 3 # Queue tail

code:

command = msg.data[0]
if command == 1: # Append to queue

error = 0
tail = contract.storage[2]

# First element is a contract address
contract = msg.data[1]

# Second element is gas price
gasprice = msg.data[2]

# Third element is gas cost
gascost = msg.data[3]

# Fourth element is value
value = msg.data[4]

# Fifth element is argument data
argument = msg.data[5]

# Assign a fixed fee for whoever *executes* this transaction
fee = 200

# Check that the msg value is enough
if msg.value < gascost + value + fee:
return(1) # Insufficient value

# Push this message to the end of the queue
contract.storage[tail] = [contract, gasprice, gascost, value, argument]
contract.storage[2] += 1

return(0)


if command == 2: # Execute the front of queue

# CRITICAL SECTION - Shouldn't be reentrant

# Don't enter a contract that's locked
if contract.storage[0] == 1:
return(2)

# Lock the contract
contract.storage[0] = 1

head = contract.storage[1]
tail = contract.storage[2]

if head >= tail: # The queue is empty, nothing to execute
contract.storage[0] = 0
return(1)


element = msg.data[1]
contract = element[0]
gasprice = element[1]
gascost = element[2]
value = element[3]
argument = element[4]

# Check the gas price is low enough
if tx.gasprice > gasprice:
contract.storage[0] = 0
return(1) # Wrong gas price

# Check the gas is sufficient to finish the transaction
if tx.gas < gascost + 200 # (remainder)
contract.storage[0] = 0
return(2) # Insufficient gas

# Call the contract
msg(gascost, contract, value, argument)

# Return to sender
send(msg.sender, fee+gascost)

# Clear the storage and advance the queue
contract.storage[tail] = 0
contract.storage[1] += 1

# Unlock the queue
contract.storage[0] = 0

# END CRITICAL SECTION
return(0)
// End of contract

As before, there's a simple example of showing this message queue in action here: https://gist.github.com/amiller/cdc42df919a9b1dcf7df#file-queue_example-py

There are several limitations of this implementation. First of all, the test doesn't show off everything I described. The message queue post in the test comes from a transaction, rather than an instruction embedded in a non-reentrant contract. Feel free to make a more thorough example :)

Second, I'm not sure what to do about the gasprice. You have to deposit enough gas at "Post" time to cover the execution of the message call, but it's the creator of the "Dispatch" that chooses the gas price. So I made "gasprice" a parameter of the message post, and execution guarantees that the actual gasprice is *no higher* than the predetermined limit. A simpler way might just be to fix a gas price globally for the queue.

Another limitation is that I had trouble getting nested arrays in arguments and storage to work correctly, so here I'm only letting you pass a single value as storage.

Also I don't think I accounted the gas needed to make the call and finish the transaction. It's possible that all these costs could vary depending on the size of the posted arguments, for example.

The contract as-is is probably not secure but could be hardened just by constraining the arguments further. For example, if some high roller enqueues a message with an extremely large gascost, then not very many external actors will have the up-front capital to create the Dispatch transaction, even though they're guaranteed to be compensated in full. So perhaps the gascost should be bounded above as well.


Comments

Sign In or Register to comment.