Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Permissioned Across-v2 bond token #226

Merged
merged 21 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions contracts/BondToken.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Address.sol";

import "./HubPoolInterface.sol";
import "./WETH9.sol";
pxrl marked this conversation as resolved.
Show resolved Hide resolved

interface _HubPool is HubPoolInterface {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer sticking to existing naming convention and not prefixing this with a _. Maybe name HubPoolExtendedInterface or something, or even IHubPool

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - I'll find something better 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented: 91c1780

// Specify the automatically-implemented rootBundleProposer() getter.
function rootBundleProposal() external pure returns (HubPoolInterface.RootBundle memory);
}

/**
* @notice Across Bond Token (ABT).
* ABT is a simple deposit contract based on WETH9. ABT is issued proportionally to any address that deposits Ether. It
* imposes address-based permissioning on the WETH9 transferFrom() function in order to constrain the movement of ABT
* into the Across v2 HubPool contract. When configured as the required HubPool bond token, ABT can dramatically reduce
* the attack surface of the HubPool by requiring that addresses are explicitly approved before they can successfully
* submit a root bundle proposal. The address-based permissiong does not constrain transfers that are needed to dispute
* a root bundle proposal, so the ability of decentralised/unknown actors to dispute is unaffected.
*/
contract BondToken is WETH9, Ownable {
using Address for address;

_HubPool public immutable hubPool;

/**
* @notice Addresses that are permitted to make HubPool root bundle proposals.
*/
mapping(address => bool) public proposers;

/**
* @notice Emitted on proposer permissions update.
*/
event ProposerModified(address proposer, bool enabled);

/**
* @notice BondToken constructor.
* @param _hubPool Address of the target HubPool contract.
*/
constructor(_HubPool _hubPool) {
name = "Across v2 Bond Token";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
name = "Across v2 Bond Token";
name = "Across Bond Token";

Not sure V2 is necessary. Do you think we should name it Proposer Bond instead of just Bond?

Copy link
Contributor Author

@pxrl pxrl Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preface: Naming things is hard :)

My thinking with the "v2" naming is that this token has a finite lifespan - it will only be in use as long as the current (Across v2) HubPool is deployed (nb. the HubPool address is immutable, per current implementation). That's why I preferred to include v2 in the name.

I'd prefer not to include "Proposer" in the name, since this token is also needed to dispute.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally don't like Versions in token names, look at UMA Voting Token V1 for example

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"v2" removed: ee0aedb

I do think it's a different scenario to UMA v1, but I can live with either :)

symbol = "ABT";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we come up with a catchier symbol? :P

Copy link
Contributor Author

@pxrl pxrl Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"AMT" => "Across Moon Token" ?

(Slightly) more seriously:
APRT => Across Proposer Responsibility Token
APPT => Across Permissioned Proposer Token
APDT => Across Permisionless Dispute Token
AHST => Across HubPool Security Token

IDK 🤷

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will think on this more haha

hubPool = _hubPool;
}

/**
* @notice Enable or disable an address as an allowed proposer. Emits a "ProposerModified" event on completion.
* @param proposer Proposer address to modify.
* @param enabled Boolean controlling whether the address is permitted to propose.
*/
function setProposer(address proposer, bool enabled) external onlyOwner {
proposers[proposer] = enabled;
emit ProposerModified(proposer, enabled);
}

/**
* @notice Transfer amt from src to dst. Prevents unauthorised root bundle proposals by blocking transfers to the
* HubPool under the following conditions:
* - The src address is not a pre-approved proposer, *and*
* - The src address is the current proposer of a HubPool root bundle.
* Falls back to the base implementation after verifying that the transfer is permitted.
* @param src Source address.
* @param dst Destination address.
* @param amt Amount to transfer.
* @return True on success.
*/
function transferFrom(
address src,
address dst,
uint256 amt
) public override returns (bool) {
if (dst == address(hubPool)) {
require(proposers[src] || hubPool.rootBundleProposal().proposer != src, "Transfer not permitted");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean proposer's can't dispute themselves? I don't think we should block such behavior

Copy link
Contributor Author

@pxrl pxrl Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check only requires that one of the conditions is true, so approved proposers can always self-dispute without impediment (and, all other users can dispute as well). There's a test that verifies that - see test/BondToken.e2e.ts line 72.

The statement hubPool.rootBundleProposal().proposer != src does seem counter-intuitive at first. It's there based on the observation that when a non-approved proposer is attempting to propose, their address is recorded as rootBundleProposal.proposer.

Copy link
Contributor

@mrice32 mrice32 Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extremely counterintuitive, but it's really clever. This is the sort of logic that I think Certora would be really good for verifying. We would probably want to verify that any address with tokens can always send a dispute.

I'm not super worried about this edge case, but this logic does seem to mean that if I proposed and then I was removed from the whitelist, I would then be no longer able to dispute myself.

Just out of curiousity, why did you not use the the check we use in the HubPool, that unclaimedPoolRebalanceLeafCount != 0 to detect disputes vs proposals. See the noActiveRequests modifier.

EDIT: That last idea doesn't work. I didn't quite get the nuance you picked up on, that the variables are written before the transfer. I think the way you've done it or checking the timestamp is probably the only way I could see this working. Very clever.

Copy link
Contributor Author

@pxrl pxrl Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super worried about this edge case, but this logic does seem to mean that if I proposed and then I was removed from the whitelist, I would then be no longer able to dispute myself.

That was also my intuition, but then I tried to implement a test for it and found the opposite - it works unexpectedly. This is similarly because the pending root bundle is deleted before the bond token transferFrom() function is called, so the disputer is no longer the proposer, even though both the proposal and dispute were submitted by the same address. It's a happy accident, I guess.

For reference, see the test at line 78 in test/BondToken.e2e.ts.

Just out of curiousity, why did you not use the the check we use in the HubPool, that unclaimedPoolRebalanceLeafCount != 0 to detect disputes vs proposals. See the noActiveRequests modifier.

That was my first attempt as well, but it doesn't work (edit: As mentioned in your update 👍). At the time of evaluation, the proposed root bundle is the version submitted by the proposer, so it seems reliably non-zero in case of both proposals and disputes.

Copy link
Contributor Author

@pxrl pxrl Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extremely counterintuitive

I've added a dev comment to the BondToken.safeTransferFrom() implementation to call this out more explicitly. See 5215b60.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same question as Matt but see your point now!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, it keeps getting weirder @pxrl. Really would love some validation on this!

}
return super.transferFrom(src, dst, amt);
}
}
77 changes: 77 additions & 0 deletions contracts/WETH9.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.0;

// Copied from the verified code from Etherscan:
// https://etherscan.io/address/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2#code
// And then updated for Solidity version 0.6. Specific changes:
pxrl marked this conversation as resolved.
Show resolved Hide resolved
// * Change `function() public` into `receive() external` and `fallback() external`
// * Change `this.balance` to `address(this).balance`
// * Ran prettier
// * Marked transferFrom() as virtual
contract WETH9 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we probably don't want two contracts in the repo called WETH9 -- this causes issues in hardhat I think. Any reason we can't just delete the interface or rename it to WETH9Interface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - I'd totally missed the existing interface. I've split this out - see #227.

string public name = "Wrapped Ether";
string public symbol = "WETH";
uint8 public decimals = 18;

event Approval(address indexed src, address indexed guy, uint256 wad);
event Transfer(address indexed src, address indexed dst, uint256 wad);
event Deposit(address indexed dst, uint256 wad);
event Withdrawal(address indexed src, uint256 wad);

mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

receive() external payable {
deposit();
}

fallback() external payable {
deposit();
}

function deposit() public payable {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}

function withdraw(uint256 wad) public {
require(balanceOf[msg.sender] >= wad);
balanceOf[msg.sender] -= wad;
payable(msg.sender).transfer(wad);
emit Withdrawal(msg.sender, wad);
}

function totalSupply() public view returns (uint256) {
return address(this).balance;
}

function approve(address guy, uint256 wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
emit Approval(msg.sender, guy, wad);
return true;
}

function transfer(address dst, uint256 wad) public returns (bool) {
return transferFrom(msg.sender, dst, wad);
}

function transferFrom(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we add a comment that this is the only difference between this contract and the real WETH9?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added here: edbee53.

address src,
address dst,
uint256 wad
) public virtual returns (bool) {
require(balanceOf[src] >= wad);

if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
require(allowance[src][msg.sender] >= wad);
allowance[src][msg.sender] -= wad;
}

balanceOf[src] -= wad;
balanceOf[dst] += wad;

emit Transfer(src, dst, wad);

return true;
}
}
22 changes: 22 additions & 0 deletions deploy/019_deploy_bond_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import "hardhat-deploy";
pxrl marked this conversation as resolved.
Show resolved Hide resolved
import { HardhatRuntimeEnvironment } from "hardhat/types/runtime";

const func = async function (hre: HardhatRuntimeEnvironment) {
const { deployments, getNamedAccounts, getChainId } = hre;
const { deploy } = deployments;

const { deployer } = await getNamedAccounts();

const chainId = parseInt(await getChainId());
const hubPool = await deploy("HubPool", { from: deployer, log: true, skipIfAlreadyDeployed: true });

await deploy("BondToken", {
from: deployer,
log: true,
skipIfAlreadyDeployed: true,
args: [hubPool.address],
libraries: {},
});
};
module.exports = func;
func.tags = ["BondToken", "mainnet"];
34 changes: 34 additions & 0 deletions test/BondToken.Admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { bondTokenFixture } from "./fixtures/BondToken.Fixture";
import { Contract, ethers, getContractFactory, randomAddress, SignerWithAddress, expect } from "./utils";

let bondToken: Contract;
let owner: SignerWithAddress, other: SignerWithAddress;

describe("BondToken Admin functions", function () {
beforeEach(async function () {
[owner, other] = await ethers.getSigners();
({ bondToken } = await bondTokenFixture());
});

it("Owner can manage proposers", async function () {
expect(await bondToken.proposers(owner.address)).to.be.false;

expect(await bondToken.proposers(other.address)).to.be.false;
expect(await bondToken.connect(owner).setProposer(other.address, true)).to.emit(bondToken, "ProposerModified");
expect(await bondToken.proposers(other.address)).to.be.true;

expect(await bondToken.proposers(other.address)).to.be.true;
expect(await bondToken.connect(owner).setProposer(other.address, false)).to.emit(bondToken, "ProposerModified");
expect(await bondToken.proposers(other.address)).to.be.false;
});

it("Non-owners can not manage proposers", async function () {
expect(await bondToken.proposers(other.address)).to.be.false;
for (const enabled of [true, false, true, false]) {
await expect(bondToken.connect(other).setProposer(other.address, enabled)).to.be.revertedWith(
"Ownable: caller is not the owner"
);
expect(await bondToken.proposers(other.address)).to.be.false;
}
});
});
60 changes: 60 additions & 0 deletions test/BondToken.Transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { bondTokenFixture } from "./fixtures/BondToken.Fixture";
import { Contract, ethers, getContractFactory, randomAddress, seedWallet, SignerWithAddress, expect } from "./utils";
import { bondAmount } from "./constants";

const bondTokenName = "Across v2 Bond Token";
const bondTokenSymbol = "ABT";
const bondTokenDecimals = 18;

let bondToken: Contract;
let hubPool: Contract;
let owner: SignerWithAddress, other: SignerWithAddress, rando: SignerWithAddress;

// Most of this functionality falls through to the underlying WETH9 implementation.
// Testing here just demonstrates that ABT doesn't break anything.
describe("BondToken ERC20 functions", function () {
beforeEach(async function () {
[owner, other, rando] = await ethers.getSigners();
({ bondToken, hubPool } = await bondTokenFixture());
});

it("Verify name, symbol and decimals", async function () {
expect(await bondToken.name()).to.equal(bondTokenName);
expect(await bondToken.symbol()).to.equal(bondTokenSymbol);
expect(await bondToken.decimals()).to.equal(bondTokenDecimals);
});

it("Anyone can deposit into ABT", async function () {
for (const signer of [owner, other]) {
const abt = bondToken.connect(signer);
await expect(abt.deposit({ value: bondAmount }))
.to.emit(bondToken, "Deposit")
.withArgs(signer.address, bondAmount);
expect((await abt.balanceOf(signer.address)).eq(bondAmount)).to.be.true;
}
});

it("ABT holders can withdraw", async function () {
for (const signer of [owner, other, rando]) {
await seedWallet(signer, [], bondToken, bondAmount);

expect((await bondToken.balanceOf(signer.address)).eq(bondAmount)).to.be.true;
await expect(bondToken.connect(signer).withdraw(bondAmount))
.to.emit(bondToken, "Withdrawal")
.withArgs(signer.address, bondAmount);
expect((await bondToken.balanceOf(signer.address)).eq("0")).to.be.true;
}
});

it("ABT holders can transfer", async function () {
await seedWallet(other, [], bondToken, bondAmount);

expect((await bondToken.balanceOf(other.address)).eq(bondAmount)).to.be.true;
expect((await bondToken.balanceOf(rando.address)).eq("0")).to.be.true;
await expect(bondToken.connect(other).transfer(rando.address, bondAmount))
.to.emit(bondToken, "Transfer")
.withArgs(other.address, rando.address, bondAmount);
expect((await bondToken.balanceOf(other.address)).eq("0")).to.be.true;
expect((await bondToken.balanceOf(rando.address)).eq(bondAmount)).to.be.true;
});
});
Loading