Safe Remote Purchases

Warning

This is example code for learning purposes. Do not use in production without thorough review and testing.

In this example, we have an escrow contract implementing a system for a trustless transaction between a buyer and a seller. In this system, a seller posts an item for sale and makes a deposit to the contract of twice the item’s value. At this moment, the contract has a balance of 2 * value. The seller can reclaim the deposit and close the sale as long as a buyer has not yet made a purchase. If a buyer is interested in making a purchase, they would make a payment and submit an equal amount for deposit (totaling 2 * value) into the contract and locking the contract from further modification. At this moment, the contract has a balance of 4 * value and the seller would send the item to buyer. Upon the buyer’s receipt of the item, the buyer will mark the item as received in the contract, thereby returning the buyer’s deposit (not payment), releasing the remaining funds to the seller, and completing the transaction.

There are certainly other ways of designing a secure escrow system with less overhead for both the buyer and seller, but for the purpose of this example, we want to explore one way how an escrow system can be implemented trustlessly.

Let’s go!

 1#pragma version >0.3.10
 2
 3# Safe Remote Purchase
 4# Originally from
 5# https://github.com/ethereum/solidity/blob/develop/docs/solidity-by-example.rst
 6# Ported to vyper and optimized.
 7
 8# Rundown of the transaction:
 9# 1. Seller posts item for sale and posts safety deposit of double the item value.
10#    Balance is 2*value.
11#    (1.1. Seller can reclaim deposit and close the sale as long as nothing was purchased.)
12# 2. Buyer purchases item (value) plus posts an additional safety deposit (Item value).
13#    Balance is 4*value.
14# 3. Seller ships item.
15# 4. Buyer confirms receiving the item. Buyer's deposit (value) is returned.
16#    Seller's deposit (2*value) + items value is returned. Balance is 0.
17
18value: public(uint256) #Value of the item
19seller: public(address)
20buyer: public(address)
21unlocked: public(bool)
22ended: public(bool)
23finalized: public(bool)
24
25@deploy
26@payable
27def __init__():
28    assert (msg.value % 2) == 0
29    assert msg.value > 0
30    self.value = msg.value // 2  # The seller initializes the contract by
31        # posting a safety deposit of 2*value of the item up for sale.
32    self.seller = msg.sender
33    self.unlocked = True
34
35@external
36def abort():
37    assert not self.finalized
38    assert self.unlocked #Is the contract still refundable?
39    assert msg.sender == self.seller # Only the seller can refund
40                                     # his deposit before any buyer purchases the item.
41    self.finalized = True
42    assert self.balance > 0 and self.balance == 2 * self.value
43    send(self.seller, self.balance)
44
45@external
46@payable
47def purchase():
48    assert not self.finalized
49    assert self.unlocked # Is the contract still open (is the item still up
50                         # for sale)?
51    assert msg.value == (2 * self.value) # Is the deposit the correct value?
52    self.buyer = msg.sender
53    self.unlocked = False
54
55@external
56def received():
57    # 1. Conditions
58    assert not self.finalized
59    assert not self.unlocked # Is the item already purchased and pending
60                             # confirmation from the buyer?
61    assert msg.sender == self.buyer
62    assert not self.ended
63
64    # 2. Effects
65    self.ended = True
66    self.finalized = True
67
68    # 3. Interaction
69    send(self.buyer, self.value) # Return the buyer's deposit (=value) to the buyer.
70    assert self.balance == 3 * self.value
71    send(self.seller, self.balance) # Return the seller's deposit (=2*value) and the
72                                    # purchase price (=value) to the seller.

This is also a moderately short contract, however a little more complex in logic. Let’s break down this contract bit by bit.

18value: public(uint256) #Value of the item
19seller: public(address)
20buyer: public(address)
21unlocked: public(bool)
22ended: public(bool)
23finalized: public(bool)

Like the other contracts, we begin by declaring our global variables public with their respective data types. Remember that the public function allows the variables to be readable by an external caller, but not writeable.

25@deploy
26@payable
27def __init__():
28    assert (msg.value % 2) == 0
29    assert msg.value > 0
30    self.value = msg.value // 2  # The seller initializes the contract by
31        # posting a safety deposit of 2*value of the item up for sale.
32    self.seller = msg.sender
33    self.unlocked = True

With a @payable decorator on the constructor, the contract creator will be required to make an initial deposit equal to twice the item’s value to initialize the contract, which will be later returned. This is in addition to the gas fees needed to deploy the contract on the blockchain, which is not returned. We assert that the deposit is divisible by 2 to ensure that the seller deposited a valid amount. The constructor stores the item’s value in the contract variable self.value and saves the contract creator into self.seller. The contract variable self.unlocked is initialized to True.

35@external
36def abort():
37    assert not self.finalized
38    assert self.unlocked #Is the contract still refundable?
39    assert msg.sender == self.seller # Only the seller can refund
40                                     # his deposit before any buyer purchases the item.
41    self.finalized = True
42    assert self.balance > 0 and self.balance == 2 * self.value
43    send(self.seller, self.balance)

The abort() method is a method only callable by the seller and while the contract is still unlocked—meaning it is callable only prior to any buyer making a purchase. As we will see in the purchase() method that when a buyer calls the purchase() method and sends a valid amount to the contract, the contract will be locked and the seller will no longer be able to call abort().

When the seller calls abort() and if the assert statements pass, the contract sends the balance back to the seller, effectively canceling the sale.

45@external
46@payable
47def purchase():
48    assert not self.finalized
49    assert self.unlocked # Is the contract still open (is the item still up
50                         # for sale)?
51    assert msg.value == (2 * self.value) # Is the deposit the correct value?
52    self.buyer = msg.sender
53    self.unlocked = False

Like the constructor, the purchase() method has a @payable decorator, meaning it can be called with a payment. For the buyer to make a valid purchase, we must first assert that the contract’s unlocked property is True and that the amount sent is equal to twice the item’s value. We then set the buyer to the msg.sender and lock the contract. At this point, the contract has a balance equal to 4 times the item value and the seller must send the item to the buyer.

55@external
56def received():
57    # 1. Conditions
58    assert not self.finalized
59    assert not self.unlocked # Is the item already purchased and pending
60                             # confirmation from the buyer?
61    assert msg.sender == self.buyer
62    assert not self.ended
63
64    # 2. Effects
65    self.ended = True
66    self.finalized = True
67
68    # 3. Interaction
69    send(self.buyer, self.value) # Return the buyer's deposit (=value) to the buyer.
70    assert self.balance == 3 * self.value
71    send(self.seller, self.balance) # Return the seller's deposit (=2*value) and the
72                                    # purchase price (=value) to the seller.

Finally, upon the buyer’s receipt of the item, the buyer can confirm their receipt by calling the received() method to distribute the funds as intended—where the seller receives 3/4 of the contract balance and the buyer receives 1/4.

By calling received(), we begin by checking that the contract is indeed locked, ensuring that a buyer had previously paid. We also ensure that this method is only callable by the buyer. If these two assert statements pass, we refund the buyer their initial deposit and send the seller the remaining funds, completing the transaction.