diff --git a/client/public/imgs/BigLevel30.svg b/client/public/imgs/BigLevel30.svg
new file mode 100644
index 000000000..9f68dc307
--- /dev/null
+++ b/client/public/imgs/BigLevel30.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/imgs/BigLevel31.svg b/client/public/imgs/BigLevel31.svg
new file mode 100644
index 000000000..327ed5246
--- /dev/null
+++ b/client/public/imgs/BigLevel31.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/imgs/Level30.svg b/client/public/imgs/Level30.svg
new file mode 100644
index 000000000..d76a2a373
--- /dev/null
+++ b/client/public/imgs/Level30.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/imgs/Level31.svg b/client/public/imgs/Level31.svg
new file mode 100644
index 000000000..5f3231460
--- /dev/null
+++ b/client/public/imgs/Level31.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/gamedata/authors.json b/client/src/gamedata/authors.json
index 4f5795c0e..a63c5d993 100644
--- a/client/src/gamedata/authors.json
+++ b/client/src/gamedata/authors.json
@@ -1,152 +1,78 @@
{
"authors": {
"ajsantander": {
- "name": [
- "Alejandro Santander"
- ],
- "emails": [
- "palebluedot@gmail.com"
- ],
- "websites": [
- "https://github.com/ajsantander"
- ],
+ "name": ["Alejandro Santander"],
+ "emails": ["palebluedot@gmail.com"],
+ "websites": ["https://github.com/ajsantander"],
"donate": "0x31a3801499618d3c4b0225b9e06e228d4795b55d"
},
"martriay": {
- "name": [
- "Martin Triay"
- ],
- "emails": [
- "martriay@gmail.com"
- ],
- "websites": [
- "https://github.com/martriay"
- ]
+ "name": ["Martin Triay"],
+ "emails": ["martriay@gmail.com"],
+ "websites": ["https://github.com/martriay"]
},
"AgeManning": {
- "name": [
- "Adrian Manning"
- ],
- "emails": [
- "age@agemanning.com"
- ],
- "websites": [
- "https://github.com/AgeManning"
- ],
+ "name": ["Adrian Manning"],
+ "emails": ["age@agemanning.com"],
+ "websites": ["https://github.com/AgeManning"],
"donate": "0x0f44CD2Ca92645Ada2E47155e4dFC0025c3E9EEc"
},
"syncikin": {
- "name": [
- "Kyle Riley"
- ],
- "emails": [
- "kyle@iosiro.com"
- ],
- "websites": [
- "https://github.com/syncikin"
- ]
+ "name": ["Kyle Riley"],
+ "emails": ["kyle@iosiro.com"],
+ "websites": ["https://github.com/syncikin"]
},
"34x4p08": {
- "name": [
- "Ivan Zakharov"
- ],
- "emails": [
- "34x4p08@gmail.com"
- ],
- "websites": [
- "https://github.com/34x4p08"
- ]
+ "name": ["Ivan Zakharov"],
+ "emails": ["34x4p08@gmail.com"],
+ "websites": ["https://github.com/34x4p08"]
},
"0age": {
- "name": [
- "0age"
- ],
- "emails": [
- "0age@protonmail.com"
- ],
- "websites": [
- "https://github.com/0age"
- ]
+ "name": ["0age"],
+ "emails": ["0age@protonmail.com"],
+ "websites": ["https://github.com/0age"]
},
"nczhu": {
- "name": [
- "Nicole Zhu"
- ],
- "emails": [
- "n@nicole.ai"
- ],
- "websites": [
- "https://github.com/nczhu"
- ]
+ "name": ["Nicole Zhu"],
+ "emails": ["n@nicole.ai"],
+ "websites": ["https://github.com/nczhu"]
},
"patrickalphac": {
- "name": [
- "Patrick Collins"
- ],
- "emails": [
- "patrick@alphachain.io"
- ],
- "websites": [
- "http://alphachain.io/blogs/"
- ],
+ "name": ["Patrick Collins"],
+ "emails": ["patrick@alphachain.io"],
+ "websites": ["http://alphachain.io/blogs/"],
"donate": "0x874437B5a42aA6E6419eC2447C9e36c278c46532"
},
"scottt": {
- "name": [
- "Scott Tsai"
- ],
- "emails": [
- "scottt.tw@gmail.com"
- ],
- "websites": [
- "http://scottt.tw"
- ],
+ "name": ["Scott Tsai"],
+ "emails": ["scottt.tw@gmail.com"],
+ "websites": ["http://scottt.tw"],
"donate": "scottt.eth"
},
"openzeppelin": {
- "name": [
- "OpenZeppelin"
- ],
- "emails": [
- "ethernaut@zeppelin.solutions"
- ],
- "websites": [
- "https://openzeppelin.com"
- ]
+ "name": ["OpenZeppelin"],
+ "emails": ["ethernaut@zeppelin.solutions"],
+ "websites": ["https://openzeppelin.com"]
},
"openzeppelin&forta": {
- "name": [
- "OpenZeppelin",
- "Forta"
- ],
- "emails": [
- "ethernaut@zeppelin.solutions",
- "info@forta.org"
- ],
- "websites": [
- "https://openzeppelin.com",
- "https://forta.org"
- ]
+ "name": ["OpenZeppelin", "Forta"],
+ "emails": ["ethernaut@zeppelin.solutions", "info@forta.org"],
+ "websites": ["https://openzeppelin.com", "https://forta.org"]
},
"ericnordelo": {
- "name": [
- "Eric Nordelo"
- ],
- "emails": [
- "eric.nordelo39@gmail.com"
- ],
- "websites": [
- "https://www.ericnordelo.com/"
- ]
+ "name": ["Eric Nordelo"],
+ "emails": ["eric.nordelo39@gmail.com"],
+ "websites": ["https://www.ericnordelo.com/"]
},
"KStasi": {
- "name": [
- "Anastasiia Kondaurova"
- ],
+ "name": ["Anastasiia Kondaurova"],
"emails": [],
- "websites": [
- "https://www.linkedin.com/in/kstasi/"
- ]
+ "websites": ["https://www.linkedin.com/in/kstasi/"]
+ },
+ "CallMeGwei": {
+ "name": ["CallMeGwei"],
+ "emails": [],
+ "websites": ["https://www.callmegwei.com/"]
}
}
-}
\ No newline at end of file
+}
diff --git a/client/src/gamedata/en/descriptions/levels/orderbook.md b/client/src/gamedata/en/descriptions/levels/orderbook.md
new file mode 100644
index 000000000..afbd882e9
--- /dev/null
+++ b/client/src/gamedata/en/descriptions/levels/orderbook.md
@@ -0,0 +1,7 @@
+A new venue for trading tokens launched in the middle of the last bear market. It promised to keep things simple and low cost by removing complexity for users. To accomplish this, the platform implemented a very simple order book which executes matching trades using an exact-match algorithm. The platform only ever supported three whitelisted tokens. A novel feature at the time, it allowed users to make fee-less trades by submitting signed orders to its front end, where the company would then pay for the transactions. Sadly, however, high gas prices have led the company to stop paying for user trades and the front end behind the platform has been shut down.
+
+The venue was mildly successful among a small group of users, and a handful of trades were successfully executed on the platform. In fact, a few of the old trades are still outstanding, but have never been filled. (Since they have yet to be filled, they are likely unfavorable trades.)
+
+Looking through the event logs for old trades shows that much more favorable trades were made in the past. Using what you have learned from the Malleability challenge, try to do much better than you could if you were just filling the currently unfilled trades. Keep in mind, this may require forcing participants to trade against each other.
+
+Can you get 100 units of the last token that was whitelisted on the platform?
diff --git a/client/src/gamedata/en/descriptions/levels/orderbook_complete.md b/client/src/gamedata/en/descriptions/levels/orderbook_complete.md
new file mode 100644
index 000000000..4b38325be
--- /dev/null
+++ b/client/src/gamedata/en/descriptions/levels/orderbook_complete.md
@@ -0,0 +1,5 @@
+Congratulations! We all wish we could accept old trades!
+
+Making other users trade with each other is pretty clever too. Remember that signatures don't guarantee uniqueness.
+
+And if your funds are with services that don't understand security - it's time to get out. Fortunately, you withdrew your funds so nobody can run this attack against you!
\ No newline at end of file
diff --git a/client/src/gamedata/en/descriptions/levels/signaturecoin.md b/client/src/gamedata/en/descriptions/levels/signaturecoin.md
new file mode 100644
index 000000000..dddc4ce11
--- /dev/null
+++ b/client/src/gamedata/en/descriptions/levels/signaturecoin.md
@@ -0,0 +1,21 @@
+This level involves an ERC20 token that allows holders to sign token transfers that anyone can submit on their behalf. In this way, the token holder doesn't need ETH to pay for gas. By reviewing the transaction history, you've noticed a completed call to
+
+```solidity
+transferWithSignature(
+ 0x3Dd8e463A4786CbE0AEFc88f6fD3fc08EeC39c0e,
+ 5000000000000000000, // 5e18
+ 1,
+ 0x3311019efd630afe231491d2afcfc626870880e99ff5907a814f55539bf1955f,
+ 0x08dcefa95e708b229636cfe4f952c87721daccd45248e0fd78e32b1ae5e33f21,
+ 27
+)
+```
+
+This means the signer transferred 5 tokens to the recipient. Your challenge is to reduce the signer's balance even further.
+
+Things that might help:
+
+- The intention of this contract is similar to [EIP2612](https://eips.ethereum.org/EIPS/eip-2612)
+- OpenZeppelin provides an [ECDSA library](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol)
+
+Both of these references are more secure than `SignatureCoin`.
\ No newline at end of file
diff --git a/client/src/gamedata/en/descriptions/levels/signaturecoin_complete.md b/client/src/gamedata/en/descriptions/levels/signaturecoin_complete.md
new file mode 100644
index 000000000..9681a7aa2
--- /dev/null
+++ b/client/src/gamedata/en/descriptions/levels/signaturecoin_complete.md
@@ -0,0 +1,12 @@
+Congratulations - you found a complementary signature!
+
+It can be surprising that ECDSA signatures come in pairs. If you're interested in the math, it may help to note:
+ - signature generation involves choosing a random value `k` and computing an elliptic curve point `P`.
+ - almost any other random value, including `-k` would also lead to a valid signature.
+ - the [the elliptic curve (secp256k1n)](https://en.bitcoin.it/wiki/Secp256k1) is symmetric so `-k` would produce the closely related point `-P`.
+ - roughly speaking, we can think of the paired signatures as negatives of each other.
+ - for this particular curve, `s` is calculated using the modulus `n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141`
+ - `n - s` is the negative of `s` in this setting, similar to how "negative 1 o'clock" would be 11 o'clock
+ - since there is no cryptographic reason to have duplicate signatures, the convention (implemented by the [OpenZeppelin ECDSA library](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol)) is to only accept the one with the smaller `s`.
+
+It is also advisable to identify conflicting transactions with the nonce rather than the signature.
\ No newline at end of file
diff --git a/client/src/gamedata/gamedata.json b/client/src/gamedata/gamedata.json
index 68cb63f39..61294bc76 100644
--- a/client/src/gamedata/gamedata.json
+++ b/client/src/gamedata/gamedata.json
@@ -446,6 +446,36 @@
"deployId": "28",
"instanceGas": 1000000,
"author": "KStasi"
+ },
+ {
+ "name": "Malleability",
+ "created": "2021-10-08",
+ "difficulty": "7",
+ "description": "signaturecoin.md",
+ "completedDescription": "signaturecoin_complete.md",
+ "levelContract": "SignatureCoinFactory.sol",
+ "instanceContract": "SignatureCoin.sol",
+ "revealCode": true,
+ "deployParams": [],
+ "deployFunds": 0,
+ "deployId": "31",
+ "instanceGas": 3600000,
+ "author": "CallMeGwei"
+ },
+ {
+ "name": "Orderbook",
+ "created": "2021-10-08",
+ "difficulty": "7",
+ "description": "orderbook.md",
+ "completedDescription": "orderbook_complete.md",
+ "levelContract": "OrderBookFactory.sol",
+ "instanceContract": "OrderBook.sol",
+ "revealCode": true,
+ "deployParams": [],
+ "deployFunds": 0,
+ "deployId": "32",
+ "instanceGas": 3600000,
+ "author": "CallMeGwei"
}
]
-}
\ No newline at end of file
+}
diff --git a/contracts/contracts/levels/OrderBook.sol b/contracts/contracts/levels/OrderBook.sol
new file mode 100644
index 000000000..8800a6d46
--- /dev/null
+++ b/contracts/contracts/levels/OrderBook.sol
@@ -0,0 +1,685 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@openzeppelin/contracts/utils/math/SafeMath.sol";
+
+contract OrderBook {
+ using SafeMath for uint256;
+
+ event TokenWhitelisted(address indexed token, uint256 index);
+
+ event OrderFilled(
+ address indexed user,
+ address indexed tokenIn,
+ address indexed tokenOut,
+ uint256 amountIn,
+ uint256 amountOut
+ );
+
+ event OrderPlaced(
+ address user,
+ uint256 tokenInIndex,
+ uint256 tokenOutIndex,
+ uint256 amountIn,
+ uint256 amountOut,
+ uint256 nonce,
+ uint8 v,
+ bytes32 r,
+ bytes32 s
+ );
+
+ event Deposit(address indexed user, address indexed token, uint256 amount);
+
+ event Withdraw(address indexed user, address indexed token, uint256 amount);
+
+ struct VerifiedSignature {
+ address signer;
+ bytes32 sigHash;
+ bytes32 orderHash;
+ }
+
+ struct Order {
+ address user;
+ address tokenIn;
+ address tokenOut;
+ uint256 amountIn;
+ uint256 amountOut;
+ }
+
+ /// @dev a token index of zero means the token is not whitelisted
+ mapping(address => uint256) tokenIndex;
+
+ // every token pair has it's own order book
+ // users want to sell tokenIn and buy tokenOut
+ // [tokenIn][tokenOut][amountIn][amountOut] => array of pending orders that match
+ mapping(address => mapping(address => mapping(uint256 => mapping(uint256 => Order[])))) newOrderBook;
+
+ // record when an order signature is used, so that it cannot be used again
+ mapping(bytes32 => bool) private _usedSignatures;
+
+ // tracks user => token => available balances
+ // (deposited balances that are not associated with an order)
+ mapping(address => mapping(address => uint256)) private _balancesAvailable;
+
+ // tracks user => token => reserved balances
+ // (balances that are no longer available for withdrawal while associated with a pending order)
+ mapping(address => mapping(address => uint256)) private _balancesReserved;
+
+ // since the scenario is a closed system, we need to mock "external" balances
+ mapping(address => mapping(address => uint256)) private _balancesExternal;
+
+ // array of all tokens that have been whitelisted
+ address[] private _whitelistedTokens;
+
+ // contract deployer becomes the owner of the contract, non-transferable
+ address private _owner;
+
+ function initialize() external {
+ require(_owner == address(0));
+ _owner = msg.sender;
+
+ // the 0th item is a placeholder, just to make mapping results more straightforward
+ _whitelistedTokens.push(address(0));
+ }
+
+ /**
+ * Allow only the owner of the contract to deposit on behalf of any user
+ * @dev Added only to facilitate Ethernaut scenario setup
+ * @param user to deposit on behalf of
+ * @param token that is being deposited
+ * @param amount of token that should be deposited for the user
+ */
+ function depositOnBehalfOf(
+ address user,
+ address token,
+ uint256 amount
+ ) external {
+ require(_owner == msg.sender, "Only owner");
+ require(
+ isTokenWhitelisted(address(token)),
+ "Cannot deposit a token that has not been whitelisted"
+ );
+ _deposit(user, token, amount);
+ }
+
+ /**
+ * Allow only the owner of the order book to whitelist tokens for trading
+ */
+ function whitelistToken(address newToken) external {
+ require(_owner == msg.sender, "Only owner");
+ require(tokenIndex[newToken] == 0, "Token already whitelisted");
+
+ // add the token to the list of whitelisted tokens
+ uint256 newIndex = _whitelistedTokens.length;
+ tokenIndex[newToken] = newIndex;
+ _whitelistedTokens.push(newToken);
+
+ emit TokenWhitelisted(newToken, newIndex);
+ }
+
+ /**
+ * @return array of all whitelisted tokens
+ */
+ function getWhitelistedTokens() public view returns (address[] memory) {
+ // the first item in our private list is a placeholder, so we want to skip it
+ uint256 whitelistedTokensLength = _whitelistedTokens.length - 1;
+
+ address[] memory whitelistedTokens = new address[](
+ whitelistedTokensLength
+ );
+
+ // construct a new array skipping over the first element in our private array
+ for (uint256 i = 0; i < whitelistedTokensLength; i++) {
+ whitelistedTokens[i] = _whitelistedTokens[i + 1];
+ }
+
+ return whitelistedTokens;
+ }
+
+ /**
+ * @param token to check for whitelist status
+ * @return whether or not the token is whitelisted
+ */
+ function isTokenWhitelisted(address token) public view returns (bool) {
+ return tokenIndex[token] != 0;
+ }
+
+ /**
+ * Allow users to deposit whitelisted erc20 tokens into their own order book accounts
+ * @param token that the msg.sender wishes to deposit into their account on the order book
+ * @param amount the amount of token to deposit
+ */
+ function deposit(address token, uint256 amount) external {
+ require(
+ isTokenWhitelisted(address(token)),
+ "Cannot deposit a token that has not been whitelisted"
+ );
+ _transferInFromUser(msg.sender, token, amount);
+ _deposit(msg.sender, token, amount);
+ }
+
+ /**
+ * Allow users to withdraw erc20 tokens from their own order book accounts
+ * @param token the msg.sender wants to withdraw from their account on the order book
+ * @param amount how much token the msg.sender wants to withdraw
+ */
+ function withdraw(address token, uint256 amount) external {
+ _decreaseAvailableBalance(msg.sender, token, amount);
+ _increaseExternalBalance(msg.sender, token, amount);
+ emit Withdraw(msg.sender, token, amount);
+ }
+
+ /**
+ * Accept signed orders and put them on the order book
+ * @param user that the order will be placed on behalf of
+ * @param tokenInIndex is the index of the whitelisted token that user wants to trade
+ * @param tokenOutIndex is the index of the whitelisted token that user wants to trade for
+ * @param amountIn the amount of tokenIn that user wants to trade
+ * @param amountOut the amount of tokenOut that user would accept for trade
+ * @param nonce number that should only be used once for message signing
+ * @param v signature component
+ * @param r signature component
+ * @param s signature component
+ */
+ function placeOrder(
+ address user,
+ uint256 tokenInIndex,
+ uint256 tokenOutIndex,
+ uint256 amountIn,
+ uint256 amountOut,
+ uint256 nonce,
+ uint8 v,
+ bytes32 r,
+ bytes32 s
+ ) public {
+ require(
+ tokenInIndex > 0 && tokenInIndex < _whitelistedTokens.length,
+ "TokenIn index out of bounds"
+ );
+
+ require(
+ tokenOutIndex > 0 && tokenOutIndex < _whitelistedTokens.length,
+ "TokenOut index out of bounds"
+ );
+
+ // token indices are in the right range, so get the actual tokens
+ address tokenIn = address(_whitelistedTokens[tokenInIndex]);
+ address tokenOut = address(_whitelistedTokens[tokenOutIndex]);
+
+ require(tokenIn != tokenOut, "Must trade across different tokens");
+ require(amountIn != 0 && amountOut != 0, "Cannot trade a zero value");
+ require(
+ amountIn <= _balancesAvailable[user][tokenIn],
+ "Order amount bid exceeds available user balance"
+ );
+
+ VerifiedSignature memory verifiedSignature = _verifyOrderSignature(
+ user,
+ tokenInIndex,
+ tokenOutIndex,
+ amountIn,
+ amountOut,
+ nonce,
+ v,
+ r,
+ s
+ );
+
+ require(verifiedSignature.signer != address(0), "Invalid signature");
+ require(
+ verifiedSignature.signer == user,
+ "Order signer does not match user specified in order"
+ );
+ require(
+ _usedSignatures[verifiedSignature.sigHash] == false,
+ "Order associated with signature already used"
+ );
+
+ // signature is "used" when the order is added to the order book
+ _usedSignatures[verifiedSignature.sigHash] = true;
+
+ emit OrderPlaced(
+ user,
+ tokenInIndex,
+ tokenOutIndex,
+ amountIn,
+ amountOut,
+ nonce,
+ v,
+ r,
+ s
+ );
+
+ // user request is legit, convert it to a live order
+ Order memory order = Order({
+ user: user,
+ tokenIn: tokenIn,
+ tokenOut: tokenOut,
+ amountIn: amountIn,
+ amountOut: amountOut
+ });
+
+ // after receiving valid order, try to fill it
+ bool orderFilled = _fillOrder(order);
+
+ // if it cannot be filled, then add it to the order book
+ if (orderFilled) {
+ emit OrderFilled(user, tokenIn, tokenOut, amountIn, amountOut);
+ } else {
+ _addOrder(order);
+ }
+ }
+
+ /**
+ * Get the total balance that the user has inside the order book
+ * @param user for balance inquiry
+ * @param token for balance inquiry
+ */
+ function getTotalBalance(address user, address token)
+ external
+ view
+ returns (uint256)
+ {
+ return
+ _balancesAvailable[user][token].add(_balancesReserved[user][token]);
+ }
+
+ /**
+ * Get the available balance that is not associated with an outstanding order
+ * @param user for balance inquiry
+ * @param token for balance inquiry
+ */
+ function getAvailableBalance(address user, address token)
+ external
+ view
+ returns (uint256)
+ {
+ return _balancesAvailable[user][token];
+ }
+
+ /**
+ * Get the reserved balance that is associated with an outstanding order
+ * @param user for balance inquiry
+ * @param token for balance inquiry
+ */
+ function getReservedBalance(address user, address token)
+ external
+ view
+ returns (uint256)
+ {
+ return _balancesReserved[user][token];
+ }
+
+ /**
+ * Get the total balance that the user has inside the order book
+ * @param user for balance inquiry
+ * @param token for balance inquiry
+ */
+ function getExternalBalance(address user, address token)
+ external
+ view
+ returns (uint256)
+ {
+ return
+ _balancesExternal[user][token].add(_balancesReserved[user][token]);
+ }
+
+ // super simple, add new order to the end of the relevant "book"
+ function _addOrder(Order memory order) private {
+ Order[] storage orderBook = newOrderBook[order.tokenIn][order.tokenOut][
+ order.amountIn
+ ][order.amountOut];
+
+ // reserved balances will go up, available balances will go down
+ _increaseReserveBalance(order.user, order.tokenIn, order.amountIn);
+ _decreaseAvailableBalance(order.user, order.tokenIn, order.amountIn);
+
+ orderBook.push(order);
+ }
+
+ function _fillOrder(Order memory order) private returns (bool) {
+ Order[] storage orderBook = newOrderBook[order.tokenOut][order.tokenIn][
+ order.amountOut
+ ][order.amountIn];
+ // there is a matching counter order
+ if (orderBook.length > 0) {
+ Order memory matchingOrder = orderBook[0];
+
+ // delete the oldest order by shifting everything left and deleting the end
+ for (uint256 i = 0; i < orderBook.length - 1; i++) {
+ orderBook[i] = orderBook[i + 1];
+ }
+ orderBook.pop();
+
+ // user that submitted order will decrease available balance of tokenIn
+ // and increase available balance of tokenOut
+ _decreaseAvailableBalance(
+ order.user,
+ order.tokenIn,
+ order.amountIn
+ );
+ _increaseAvailableBalance(
+ order.user,
+ order.tokenOut,
+ order.amountOut
+ );
+
+ // user with standing order will have their tokenIn reserve balance decrease
+ // but their available balance of their tokenOut will increase
+ _decreaseReserveBalance(
+ matchingOrder.user,
+ matchingOrder.tokenIn,
+ matchingOrder.amountIn
+ );
+ _increaseAvailableBalance(
+ matchingOrder.user,
+ matchingOrder.tokenOut,
+ matchingOrder.amountOut
+ );
+
+ emit OrderFilled(
+ order.user,
+ order.tokenIn,
+ order.tokenOut,
+ order.amountIn,
+ order.amountOut
+ );
+
+ return true;
+ } else {
+ // no matching order was found
+ return false;
+ }
+ }
+
+ function _verifyOrderSignature(
+ address user,
+ uint256 tokenInIndex,
+ uint256 tokenOutIndex,
+ uint256 amountIn,
+ uint256 amountOut,
+ uint256 nonce,
+ uint8 v,
+ bytes32 r,
+ bytes32 s
+ ) private pure returns (VerifiedSignature memory) {
+ bytes32 userOrderHash = keccak256(
+ abi.encode(
+ user,
+ tokenInIndex,
+ tokenOutIndex,
+ amountIn,
+ amountOut,
+ nonce
+ )
+ );
+ bytes32 msgHash = keccak256(
+ abi.encodePacked("\x19Ethereum Signed Message:\n32", userOrderHash)
+ );
+ bytes32 sigHash = keccak256(abi.encodePacked(r, s, v));
+
+ address signer = ecrecover(msgHash, v, r, s);
+
+ return VerifiedSignature(signer, sigHash, userOrderHash);
+ }
+
+ function _transferInFromUser(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _decreaseExternalBalance(user, token, amount);
+ }
+
+ function _deposit(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _increaseAvailableBalance(user, token, amount);
+ emit Deposit(user, token, amount);
+ }
+
+ function _increaseAvailableBalance(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _balancesAvailable[user][token] = _balancesAvailable[user][token].add(
+ amount
+ );
+ }
+
+ function _decreaseAvailableBalance(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _balancesAvailable[user][token] = _balancesAvailable[user][token].sub(
+ amount
+ );
+ }
+
+ function _increaseReserveBalance(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _balancesReserved[user][token] = _balancesReserved[user][token].add(
+ amount
+ );
+ }
+
+ function _decreaseReserveBalance(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _balancesReserved[user][token] = _balancesReserved[user][token].sub(
+ amount
+ );
+ }
+
+ function _increaseExternalBalance(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _balancesExternal[user][token] = _balancesExternal[user][token].add(
+ amount
+ );
+ }
+
+ function _decreaseExternalBalance(
+ address user,
+ address token,
+ uint256 amount
+ ) private {
+ _balancesExternal[user][token] = _balancesExternal[user][token].sub(
+ amount
+ );
+ }
+
+ // the function below is used only to set up the scenario
+ function setupScenario() external {
+ require(msg.sender == _owner);
+
+ ///Setup trades
+ placeOrder(
+ 0xE3EeaDaD850BCf71390961945d3Bae854C41d276,
+ 1,
+ 3,
+ 10000000000000000000,
+ 35000000000000000000,
+ 1,
+ 27,
+ 0x796fb77f8f449caf9fa32c4241f8c08f67b6115aad013ab4054f21f08d595d0a,
+ 0x1536fa7a5528b563c1180b0e22562754068e417173d270f41583cde1747f63c5
+ );
+ placeOrder(
+ 0xb9a9B73CE551c06EEA59143B9BEdaA8195F517FF,
+ 1,
+ 3,
+ 10000000000000000000,
+ 35000000000000000000,
+ 1,
+ 28,
+ 0xda654da61dd9c44f0f51fa81befd225261451a71114826fa067661b09023fafb,
+ 0x30936a5bd7e2cdf7719c9aef514124e4285b0e752a4ed28e147d3340e114ba93
+ );
+ placeOrder(
+ 0xE3EeaDaD850BCf71390961945d3Bae854C41d276,
+ 2,
+ 3,
+ 45000000000000000000,
+ 65000000000000000000,
+ 2,
+ 27,
+ 0x807347916985693a9953efbe480f7114decfae746f9c98495c1372601194b130,
+ 0x75acb4c862172d07487add961c604fb1294385bee27b45fed95c9b521b5b0198
+ );
+ placeOrder(
+ 0xf8f398e1b3Be169f4A1aEA3553ad8c3550B58a5d,
+ 1,
+ 2,
+ 15000000000000000000,
+ 40000000000000000000,
+ 1,
+ 28,
+ 0xee56a323e26efd80a101f2db48c4808ce49f92da90b72ff6fe0ad91c3b6a3987,
+ 0x7405b790af41fc2cbefc0f96a5cec62f80bcc639d392a2580cc13af765160c69
+ );
+ placeOrder(
+ 0xF0902f8573acfD685978450Bc2485c002471D4B0,
+ 2,
+ 1,
+ 45000000000000000000,
+ 15000000000000000000,
+ 1,
+ 27,
+ 0xd1c2f516e1cfd12b20b7b3bd1fc2e2a3e1a84d1c68dc95f0e53149a4b23165d3,
+ 0x69983ccdc22751e74d18310d9dc526d8059fb88b675cf03a363a407ab59f2657
+ );
+ placeOrder(
+ 0xF0902f8573acfD685978450Bc2485c002471D4B0,
+ 3,
+ 1,
+ 55000000000000000000,
+ 20000000000000000000,
+ 2,
+ 28,
+ 0x3c3ad10607ffd06a1cf8b90c051e2597f780cc0466f1e619e5177f0e805909bb,
+ 0x70c72727a27d60a2b2f8dfdb3787408d8852eab1aa60d8d886a091f3ceb9725b
+ );
+ placeOrder(
+ 0xb9a9B73CE551c06EEA59143B9BEdaA8195F517FF,
+ 3,
+ 2,
+ 100000000000000000000,
+ 80000000000000000000,
+ 2,
+ 28,
+ 0x303491a774261f8aff19a3e963ac4ad70a81751f6fc2b40d3a65b4a538a2f4c1,
+ 0x18a892cd45a22eb91b7c5393c7bce3e8a56ba3328d8531e108a5ecccb16c0d52
+ );
+ placeOrder(
+ 0x421244A7A8809c73a9D6806b91E322c85e9574df,
+ 1,
+ 2,
+ 15000000000000000000,
+ 45000000000000000000,
+ 1,
+ 27,
+ 0xd4d3f754d33c10d935e7c50c112e97ea4040333829eb5c90a337ef6c4f7d3a6c,
+ 0x4ea451d00c9eb3d17752d7bd08a321c8db38da0e6aaef088dad3edc669295ae1
+ );
+ placeOrder(
+ 0x421244A7A8809c73a9D6806b91E322c85e9574df,
+ 2,
+ 3,
+ 80000000000000000000,
+ 100000000000000000000,
+ 2,
+ 27,
+ 0xc19b651dfd49b573eeb4e643e2551d862a8bf85737fbf38293ef8210ebe9c7a8,
+ 0x1ec2df1020c9ab7102fae8affca82522eca388fa627374c1699fa3250213c534
+ );
+ placeOrder(
+ 0x421244A7A8809c73a9D6806b91E322c85e9574df,
+ 3,
+ 1,
+ 35000000000000000000,
+ 10000000000000000000,
+ 3,
+ 27,
+ 0x1036d281926e4a2b4d173ee2b3dfc90a21fd7b56b90c448108aced34e873e81f,
+ 0x563eb8893d741a3a0de1ce5710ba1f0ba25426cb05fc36c2bcfeeb040faab0c5
+ );
+ placeOrder(
+ 0xE3EeaDaD850BCf71390961945d3Bae854C41d276,
+ 1,
+ 3,
+ 20000000000000000000,
+ 55000000000000000000,
+ 3,
+ 28,
+ 0x6be7be29bc95413cbfeba734e309d70ab203110c1988029b117418126bb8c91c,
+ 0x0f8a5caaee09baf6247353d66ced59ce0ec534d3817397b2f60d1f8869c4bbb9
+ );
+ placeOrder(
+ 0xE3EeaDaD850BCf71390961945d3Bae854C41d276,
+ 1,
+ 3,
+ 20000000000000000000,
+ 55000000000000000000,
+ 4,
+ 28,
+ 0x4bd75675db408eda4a5b9335f34f49a1ec5e42ca29ba01331b2acba983fb0449,
+ 0x7bd35f2193e9293203fb1c32042581c273ddb57ce540d5faf91d131c5b167d99
+ );
+ placeOrder(
+ 0xb9a9B73CE551c06EEA59143B9BEdaA8195F517FF,
+ 2,
+ 1,
+ 40000000000000000000,
+ 15000000000000000000,
+ 3,
+ 27,
+ 0x347b01aa24d1a1abcb184c06dfcddb35642f9d30b9094118773bc23a5a5840aa,
+ 0x272a8fd029df047d72ae46755b34784ad02f71fc2cb6d25d787ac0312e7bab34
+ );
+ placeOrder(
+ 0xf8f398e1b3Be169f4A1aEA3553ad8c3550B58a5d,
+ 1,
+ 2,
+ 35000000000000000000,
+ 80000000000000000000,
+ 2,
+ 27,
+ 0xa7910bae6403166b76f0855180f264202ab01458b574e00211e7d0c7c66b8690,
+ 0x7a89cd54bed193cbce9a7634fcfac76eee7fd2afb10a2a5be292c5e075fa4221
+ );
+ placeOrder(
+ 0xE3EeaDaD850BCf71390961945d3Bae854C41d276,
+ 2,
+ 3,
+ 30000000000000000000,
+ 32000000000000000000,
+ 5,
+ 28,
+ 0x2cf44235bfdf5701739720552e6fde0ae5ba90ebc62b82844b947af0dc0cc53b,
+ 0x1b68e3f6e46d73e4d5a04b4215f6500d543cfbf12a09328c0303bf997c4176a6
+ );
+ placeOrder(
+ 0x421244A7A8809c73a9D6806b91E322c85e9574df,
+ 3,
+ 2,
+ 70000000000000000000,
+ 80000000000000000000,
+ 4,
+ 28,
+ 0x1563d2fd843b778fdab9be9045ed32e1d8319cf95762740a0b026f159193c89b,
+ 0x77b28cd62da14933761197660041e3150c894d1e16eb560be20a9de08e8fbe66
+ );
+ }
+}
diff --git a/contracts/contracts/levels/OrderBookDummyToken.sol b/contracts/contracts/levels/OrderBookDummyToken.sol
new file mode 100644
index 000000000..744d8ea2b
--- /dev/null
+++ b/contracts/contracts/levels/OrderBookDummyToken.sol
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+contract OrderBookDummyToken is ERC20 {
+ constructor(
+ string memory name,
+ string memory symbol,
+ uint256 initialSupply
+ ) ERC20(name, symbol) {
+ _mint(msg.sender, initialSupply);
+ }
+}
diff --git a/contracts/contracts/levels/OrderBookFactory.sol b/contracts/contracts/levels/OrderBookFactory.sol
new file mode 100644
index 000000000..280089303
--- /dev/null
+++ b/contracts/contracts/levels/OrderBookFactory.sol
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "./base/Level.sol";
+import "./OrderBook.sol";
+import "./OrderBookProxy.sol";
+
+/// @title Factory contract for OrderBook
+/// @author Andrew, Nikesh, Rick
+/// @notice Besides deploying an OrderBook, this contract also deploys 3 dummy tokens and sets initial token balances for dummy users
+/// @dev Initial setup value can be changed as needed
+contract OrderBookFactory is Level {
+ uint256 constant scalar = 1e18;
+
+ address private _implementation;
+
+ uint256[3] TOKEN_SUPPLIES = [1000, 1000, 1000];
+
+ ///To hold deployed token addresses
+ address[3] public tokenAdds = [address(0xA), address(0xB), address(0xC)];
+
+ ///Dummy user addresses and their initial 3 token balances in 2 arrays. This also sets the initial balance for the player (msg.sender)
+ address[6] USERS = [
+ 0xE3EeaDaD850BCf71390961945d3Bae854C41d276,
+ 0xf8f398e1b3Be169f4A1aEA3553ad8c3550B58a5d,
+ 0xF0902f8573acfD685978450Bc2485c002471D4B0,
+ 0xb9a9B73CE551c06EEA59143B9BEdaA8195F517FF,
+ 0x421244A7A8809c73a9D6806b91E322c85e9574df,
+ msg.sender
+ ];
+
+ uint256[3][6] INITIAL_TOKEN_BALANCES = [
+ [100, 75, 0],
+ [50, 30, 50],
+ [10, 80, 60],
+ [200, 200, 180],
+ [20, 40, 40],
+ [0, 80, 0]
+ ];
+
+ ///This level will be successful if the challenger achieves this many TokenC tokens
+ uint256 VERIFY_THRESHOLD_TOKEN_C = 100 * scalar;
+
+ constructor(address implementation) {
+ ///Setup checks
+ require(
+ USERS.length == INITIAL_TOKEN_BALANCES.length,
+ "Incorrect USERS or INITIAL_TOKEN_BALANCES"
+ );
+
+ _implementation = implementation;
+ }
+
+ function createInstance(address) public payable override returns (address) {
+ ///Deploy orderBook instance, which is really a proxy of a deployed OrderBook
+ OrderBook orderBook_instance = OrderBook(
+ address(new OrderBookProxy(_implementation))
+ );
+
+ // initialize that newly created proxy instance
+ orderBook_instance.initialize();
+
+ ///* Whitelist 3 tokens with OrderBook
+ ///* Transfer all outstanding supply to the orderBook. Note: this amount should exceed the user deposits
+ ///* Call depositOnBehalfOf() for each user and token
+
+ for (uint256 i = 0; i < TOKEN_SUPPLIES.length; i++) {
+ orderBook_instance.whitelistToken(tokenAdds[i]);
+
+ for (uint256 j = 0; j < USERS.length; j++) {
+ if (INITIAL_TOKEN_BALANCES[j][i] > 0) {
+ orderBook_instance.depositOnBehalfOf(
+ USERS[j],
+ address(tokenAdds[i]),
+ INITIAL_TOKEN_BALANCES[j][i] * scalar
+ );
+ }
+ }
+ }
+
+ orderBook_instance.setupScenario();
+
+ ///Return orderBook address
+ return address(orderBook_instance);
+ }
+
+ ///Does the player have enough TokenC tokens
+ function validateInstance(address payable _instance, address _player)
+ public
+ view
+ override
+ returns (bool)
+ {
+ return
+ OrderBook(_instance).getExternalBalance(_player, tokenAdds[2]) >=
+ VERIFY_THRESHOLD_TOKEN_C;
+ }
+}
diff --git a/contracts/contracts/levels/OrderBookProxy.sol b/contracts/contracts/levels/OrderBookProxy.sol
new file mode 100644
index 000000000..1a0d6c5d9
--- /dev/null
+++ b/contracts/contracts/levels/OrderBookProxy.sol
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@openzeppelin/contracts/proxy/Proxy.sol";
+
+contract OrderBookProxy is Proxy {
+ address private _address;
+
+ constructor(address fixedAddress) {
+ _address = fixedAddress;
+ }
+
+ function _implementation() internal view override returns (address) {
+ return _address;
+ }
+}
diff --git a/contracts/contracts/levels/RecoveryFactory.sol b/contracts/contracts/levels/RecoveryFactory.sol
index 40ea7404f..11d364ad8 100644
--- a/contracts/contracts/levels/RecoveryFactory.sol
+++ b/contracts/contracts/levels/RecoveryFactory.sol
@@ -2,30 +2,54 @@
pragma solidity ^0.8.0;
-import './base/Level.sol';
-import './Recovery.sol';
+import "./base/Level.sol";
+import "./Recovery.sol";
contract RecoveryFactory is Level {
-
- mapping (address => address) lostAddress;
-
- function createInstance(address _player) override public payable returns (address) {
- require(msg.value >= 0.001 ether, "Must send at least 0.001 ETH");
-
- Recovery recoveryInstance;
- recoveryInstance = new Recovery();
- // create a simple token
- recoveryInstance.generateToken("InitialToken", uint(100000));
- // the lost address
- lostAddress[address(recoveryInstance)] = address(uint160(uint256(keccak256(abi.encodePacked(uint8(0xd6), uint8(0x94), recoveryInstance, uint8(0x01))))));
- // Send it some ether
- (bool result,) = lostAddress[address(recoveryInstance)].call{value: msg.value}("");
- require(result);
-
- return address(recoveryInstance);
- }
-
- function validateInstance(address payable _instance, address) override public view returns (bool) {
- return address(lostAddress[_instance]).balance == 0;
- }
+ mapping(address => address) lostAddress;
+
+ function createInstance(address _player)
+ public
+ payable
+ override
+ returns (address)
+ {
+ require(msg.value >= 0.001 ether, "Must send at least 0.001 ETH");
+
+ Recovery recoveryInstance;
+ recoveryInstance = new Recovery();
+ // create a simple token
+ recoveryInstance.generateToken("InitialToken", uint256(100000));
+ // the lost address
+ lostAddress[address(recoveryInstance)] = address(
+ uint160(
+ uint256(
+ keccak256(
+ abi.encodePacked(
+ uint8(0xd6),
+ uint8(0x94),
+ recoveryInstance,
+ uint8(0x01)
+ )
+ )
+ )
+ )
+ );
+ // Send it some ether
+ (bool result, ) = lostAddress[address(recoveryInstance)].call{
+ value: msg.value
+ }("");
+ require(result);
+
+ return address(recoveryInstance);
+ }
+
+ function validateInstance(address payable _instance, address)
+ public
+ view
+ override
+ returns (bool)
+ {
+ return address(lostAddress[_instance]).balance == 0;
+ }
}
diff --git a/contracts/contracts/levels/SignatureCoin.sol b/contracts/contracts/levels/SignatureCoin.sol
new file mode 100644
index 000000000..e51c9258e
--- /dev/null
+++ b/contracts/contracts/levels/SignatureCoin.sol
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
+
+contract SignatureCoin is ERC20 {
+ mapping(bytes32 => bool) public usedSignatures;
+
+ constructor(address source) ERC20("SignatureCoin", "SGC") {
+ _mint(source, 100 * uint256(10)**decimals());
+ }
+
+ function transferWithSignature(
+ address recipient,
+ uint256 amount,
+ uint256 nonce,
+ bytes32 r,
+ bytes32 s,
+ uint8 v
+ ) public {
+ bytes32 message = keccak256(abi.encode(recipient, amount, nonce));
+ bytes32 msgHash = keccak256(
+ abi.encodePacked("\x19Ethereum Signed Message:\n32", message)
+ );
+ bytes32 sigHash = keccak256(abi.encodePacked(r, s, v));
+
+ require(
+ usedSignatures[sigHash] == false,
+ "SignatureCoin: transfer already executed"
+ );
+ usedSignatures[sigHash] = true;
+
+ address signer = ecrecover(msgHash, v, r, s);
+ require(signer != address(0), "SignatureCoin: invalid signature");
+
+ _transfer(signer, recipient, amount);
+ }
+}
diff --git a/contracts/contracts/levels/SignatureCoinFactory.sol b/contracts/contracts/levels/SignatureCoinFactory.sol
new file mode 100644
index 000000000..52d6deb8c
--- /dev/null
+++ b/contracts/contracts/levels/SignatureCoinFactory.sol
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "./base/Level.sol";
+import "./SignatureCoin.sol";
+
+contract SignatureCoinFactory is Level {
+ // this represents an arbitrary transaction signature that was computed offline
+ address constant source = 0xb7acF2dC3BC5C40F7e99991C623a7469af1F03F9;
+ address constant recipient = 0x3Dd8e463A4786CbE0AEFc88f6fD3fc08EeC39c0e;
+ uint256 constant amount = 5 * 10**18;
+ uint256 constant nonce = 1;
+ bytes32 constant r =
+ 0x3311019efd630afe231491d2afcfc626870880e99ff5907a814f55539bf1955f;
+ bytes32 constant s =
+ 0x08dcefa95e708b229636cfe4f952c87721daccd45248e0fd78e32b1ae5e33f21;
+ uint8 constant v = 27;
+ uint256 constant balance = 95 * 10**18;
+
+ function createInstance(address) public payable override returns (address) {
+ SignatureCoin token = new SignatureCoin(source);
+ token.transferWithSignature(recipient, amount, nonce, r, s, v);
+ return address(token);
+ }
+
+ function validateInstance(address payable _instance, address)
+ public
+ view
+ override
+ returns (bool)
+ {
+ SignatureCoin coin = SignatureCoin(_instance);
+ return coin.balanceOf(source) < balance;
+ }
+}
diff --git a/contracts/test/levels/OrderBook.test.js b/contracts/test/levels/OrderBook.test.js
new file mode 100644
index 000000000..bab066fee
--- /dev/null
+++ b/contracts/test/levels/OrderBook.test.js
@@ -0,0 +1,211 @@
+/*eslint no-undef: "off"*/
+const ethers = require('ethers');
+
+const OrderBookFactory = artifacts.require('./levels/OrderBookFactory.sol');
+const OrderBook = artifacts.require('./levels/OrderBook.sol');
+const utils = require('../utils/TestUtils');
+const BigNumber = ethers.BigNumber;
+const BN = Web3.utils.BN;
+
+contract('OrderBook', function () {
+ let ethernaut, level, instance, implementation;
+ let users;
+
+ let player = new ethers.Wallet(
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
+ );
+ const decimals = 18;
+ const scale = new BN(10).pow(new BN(decimals));
+ const encoder = new ethers.utils.AbiCoder();
+ let tokens;
+
+ beforeEach(async function () {
+ ethernaut = await utils.getEthernautWithStatsProxy();
+ implementation = await OrderBook.new();
+ level = await OrderBookFactory.new(implementation.address);
+ await ethernaut.registerLevel(level.address);
+
+ users = {
+ Alice: generateWallet('Alice'),
+ Bob: generateWallet('Bob'),
+ Charlie: generateWallet('Charlie'),
+ Diane: generateWallet('Diane'),
+ Evelyn: generateWallet('Evelyn'),
+ };
+ });
+
+ beforeEach(async function () {
+ instance = await utils.createLevelInstance(
+ ethernaut,
+ level.address,
+ player.address,
+ OrderBook
+ );
+
+ tokens = await instance.getWhitelistedTokens();
+ });
+
+ describe('Initialization', async function () {
+ // these are the balances after the factory has run the setup trades
+ const initialScenarioBalances = {
+ Alice: [70, 75, 90],
+ Bob: [35, 70, 50],
+ Charlie: [45, 35, 5],
+ Diane: [215, 240, 80],
+ Evelyn: [15, 5, 105],
+ };
+
+ Object.keys(initialScenarioBalances).map((name) =>
+ initialScenarioBalances[name].map((balance, idx) => {
+ it(`should give ${name} ${balance} units of token ${idx}`, async function () {
+ const totalBalance = await instance.getTotalBalance(
+ users[name].address,
+ tokens[idx]
+ );
+ assert(totalBalance.eq(scale.mul(new BN(balance))));
+ });
+ })
+ );
+
+ it('should not be completed', async function () {
+ const isComplete = await utils.submitLevelInstance(
+ ethernaut,
+ level.address,
+ instance.address,
+ player.address
+ );
+ assert.equal(isComplete, false);
+ });
+ });
+
+ describe('Malleability attack', async function () {
+ // this is the first transaction we want to replay
+ // it will force Evelyn to accept Diane's old offer, which will give Diane an extra 35 token[2]
+ // so we can replay the next transaction
+ // the details can be taken from the contract events
+ const target0 = {
+ address: '0x421244a7a8809c73a9d6806b91e322c85e9574df',
+ sellIdx: 3,
+ buyIdx: 1,
+ sellAmount: BigNumber.from(10).pow(decimals).mul(35),
+ buyAmount: BigNumber.from(10).pow(decimals).mul(10),
+ nonce: 3,
+ v: 27,
+ r: '0x1036d281926e4a2b4d173ee2b3dfc90a21fd7b56b90c448108aced34e873e81f',
+ s: '0x563eb8893d741a3a0de1ce5710ba1f0ba25426cb05fc36c2bcfeeb040faab0c5',
+ };
+
+ // this is the second transaction we want to replay
+ // it will force Diane to offer us 100 token[2] in exchange for 80 token[1]
+ // note that this is much better than current market price
+ // the details can be taken from the contract events
+ const target1 = {
+ address: '0xb9a9B73CE551c06EEA59143B9BEdaA8195F517FF',
+ sellIdx: 3,
+ buyIdx: 2,
+ sellAmount: BigNumber.from(10).pow(decimals).mul(100),
+ buyAmount: BigNumber.from(10).pow(decimals).mul(80),
+ nonce: 2,
+ v: 28,
+ r: '0x303491a774261f8aff19a3e963ac4ad70a81751f6fc2b40d3a65b4a538a2f4c1',
+ s: '0x18a892cd45a22eb91b7c5393c7bce3e8a56ba3328d8531e108a5ecccb16c0d52',
+ };
+
+ beforeEach(async function () {
+ const altSig0 = alternateSignature(target0.r, target0.s, target0.v);
+ const altSig1 = alternateSignature(target1.r, target1.s, target1.v);
+
+ // force Evelyn to give Diane 35 token[2]
+ await instance.placeOrder(
+ target0.address,
+ target0.sellIdx,
+ target0.buyIdx,
+ target0.sellAmount,
+ target0.buyAmount,
+ target0.nonce,
+ altSig0.v,
+ altSig0.r,
+ altSig0.s
+ );
+ // force Diane to offer 100 token[2]
+ await instance.placeOrder(
+ target1.address,
+ target1.sellIdx,
+ target1.buyIdx,
+ target1.sellAmount,
+ target1.buyAmount,
+ target1.nonce,
+ altSig1.v,
+ altSig1.r,
+ altSig1.s
+ );
+ // accept the offer
+ const sellAmountBN = scale.mul(new BN(80));
+ const buyAmountBN = scale.mul(new BN(100));
+ const encoding = encoder.encode(
+ ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256'],
+ [
+ player.address,
+ 2,
+ 3,
+ sellAmountBN.toString(),
+ buyAmountBN.toString(),
+ 1,
+ ]
+ );
+ const message = ethers.utils.keccak256(encoding);
+ const hash = ethers.utils.arrayify(message);
+ const flatSig = await player.signMessage(hash);
+ const sig = ethers.utils.splitSignature(flatSig);
+ await instance.placeOrder(
+ player.address,
+ 2,
+ 3,
+ sellAmountBN,
+ buyAmountBN,
+ 1,
+ sig.v,
+ sig.r,
+ sig.s
+ );
+ // retrieve the tokens
+ await instance.withdraw(tokens[2], buyAmountBN, { from: player.address });
+ });
+
+ it('should retrieve 100 units of token 2', async function () {
+ const balance = await instance.getExternalBalance(
+ player.address,
+ tokens[2]
+ );
+ assert(balance.eq(scale.mul(new BN(100))));
+ });
+
+ it('should complete the challenge', async function () {
+ const isComplete = await utils.submitLevelInstance(
+ ethernaut,
+ level.address,
+ instance.address,
+ player.address
+ );
+ assert.equal(isComplete, true);
+ });
+ });
+});
+
+function generateWallet(seed) {
+ return new ethers.Wallet(
+ ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`OrderBook:${seed}`))
+ );
+}
+
+function alternateSignature(r, s, v) {
+ // This is a property of the secp256k1 elliptic curve
+ const order =
+ '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141';
+
+ return {
+ r: r,
+ s: BigNumber.from(order).sub(s).toHexString(),
+ v: 55 - v, // map 27 to 28 and vice versa
+ };
+}
diff --git a/contracts/test/levels/SignatureCoin.test.js b/contracts/test/levels/SignatureCoin.test.js
new file mode 100644
index 000000000..f3081def1
--- /dev/null
+++ b/contracts/test/levels/SignatureCoin.test.js
@@ -0,0 +1,147 @@
+/*eslint no-undef: "off"*/
+const ethers = require('ethers');
+
+const SignatureCoinFactory = artifacts.require(
+ './levels/SignatureCoinFactory.sol'
+);
+const SignatureCoin = artifacts.require('./levels/SignatureCoin.sol');
+const utils = require('../utils/TestUtils');
+const BN = Web3.utils.BN;
+const { expectRevert } = require('openzeppelin-test-helpers');
+
+contract('SignatureCoin', function (accounts) {
+ let ethernaut, level, instance;
+ let player = accounts[0];
+
+ let source, recipient, amount, nonce, decimals, sig, scale;
+
+ before(async function () {
+ ethernaut = await utils.getEthernautWithStatsProxy();
+ level = await SignatureCoinFactory.new();
+ await ethernaut.registerLevel(level.address);
+
+ // the source and recipient accounts are two arbitrary addresses chosen psuedorandomly
+ const privateKeySource = ethers.utils.keccak256(
+ ethers.utils.toUtf8Bytes('SignatureCoin:source')
+ );
+ const privateKeyRecipient = ethers.utils.keccak256(
+ ethers.utils.toUtf8Bytes('SignatureCoin:recipient')
+ );
+ source = new ethers.Wallet(privateKeySource);
+ recipient = new ethers.Wallet(privateKeyRecipient);
+
+ // the signature corresponds to an arbitrary token transfer
+ decimals = 18;
+ scale = new BN(10).pow(new BN(decimals));
+ amount = ethers.BigNumber.from(10).pow(decimals).mul(5);
+ nonce = 1;
+ const encoder = new ethers.utils.AbiCoder();
+ const encoding = encoder.encode(
+ ['address', 'uint256', 'uint256'],
+ [recipient.address, amount, nonce]
+ );
+ const message = ethers.utils.keccak256(encoding);
+ const hash = ethers.utils.arrayify(message);
+ const flatSig = await source.signMessage(hash);
+ sig = ethers.utils.splitSignature(flatSig);
+ });
+
+ beforeEach(async function () {
+ instance = await utils.createLevelInstance(
+ ethernaut,
+ level.address,
+ player,
+ SignatureCoin
+ );
+ });
+
+ describe('Initialization', async function () {
+ it('should provide 95 tokens to the source', async function () {
+ const balance = await instance.balanceOf(source.address);
+ assert(balance.eq(scale.mul(new BN(95))));
+ });
+
+ it('should provide 5 tokens to the recipient', async function () {
+ const balance = await instance.balanceOf(recipient.address);
+ assert(balance.eq(scale.mul(new BN(5))));
+ });
+
+ it('should not be completed', async function () {
+ const isComplete = await utils.submitLevelInstance(
+ ethernaut,
+ level.address,
+ instance.address,
+ player
+ );
+ assert.equal(isComplete, false);
+ });
+ });
+
+ describe('Replay the same signature', async function () {
+ it('should fail to execute', async function () {
+ expectRevert(
+ instance.transferWithSignature(
+ recipient.address,
+ amount,
+ nonce,
+ sig.r,
+ sig.s,
+ sig.v
+ ),
+ 'SignatureCoin: transfer already executed'
+ );
+ });
+ });
+
+ describe('Malleability attack', async function () {
+ // This is taken from the question. In a real attack, it would be read from the original transaction
+ // note: it matches the global `sig` variable but we redefine it here so the attack is standalone
+ const sig = {
+ r: '0x3311019efd630afe231491d2afcfc626870880e99ff5907a814f55539bf1955f',
+ s: '0x08dcefa95e708b229636cfe4f952c87721daccd45248e0fd78e32b1ae5e33f21',
+ v: 27,
+ };
+
+ // This is a property of the secp256k1 elliptic curve
+ const order =
+ '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141';
+
+ let altSig;
+ beforeEach(async function () {
+ altSig = {
+ r: sig.r,
+ s: ethers.BigNumber.from(order).sub(sig.s).toHexString(),
+ v: 55 - sig.v, // map 27 to 28 and vice versa
+ };
+
+ await instance.transferWithSignature(
+ recipient.address,
+ amount,
+ nonce,
+ altSig.r,
+ altSig.s,
+ altSig.v
+ );
+ });
+
+ it('should set the source balance to 90', async function () {
+ const balance = await instance.balanceOf(source.address);
+ assert(balance.eq(scale.mul(new BN(90))));
+ });
+
+ it('should set the recipient balance to 10', async function () {
+ const balance = await instance.balanceOf(recipient.address);
+ assert(balance.eq(scale.mul(new BN(10))));
+ });
+
+ it('should complete the challenge', async function () {
+ const isComplete = await utils.submitLevelInstance(
+ ethernaut,
+ level.address,
+ instance.address,
+ player
+ );
+ assert.equal(isComplete, true);
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 5f588484d..b5c3fd815 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -19447,4 +19447,4 @@ yargs@^4.7.1:
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"
- integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+ integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
\ No newline at end of file