diff --git a/package.json b/package.json index 4a3c7f780241..3ba44a57c61f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint:toc": "doctoc --title=\"**Table of Contents**\" ./specs ./meta" }, "dependencies": { - "@eth-optimism/core-utils": "^0.8.1" + "@eth-optimism/core-utils": "^0.8.1", + "@ethersproject/constants": "^5.6.0" } } diff --git a/packages/contracts/helpers/index.ts b/packages/contracts/helpers/index.ts new file mode 100644 index 000000000000..639f068d0830 --- /dev/null +++ b/packages/contracts/helpers/index.ts @@ -0,0 +1,216 @@ +import * as RLP from '@ethersproject/rlp' +import { BigNumber, BigNumberish } from '@ethersproject/bignumber' +import { getAddress } from '@ethersproject/address' +import { + hexConcat, + stripZeros, + zeroPad, + arrayify, + BytesLike, +} from '@ethersproject/bytes' +import { keccak256 } from '@ethersproject/keccak256' +import { Zero } from '@ethersproject/constants' +import { ContractReceipt, Event } from '@ethersproject/contracts' + +function formatNumber(value: BigNumberish, name: string): Uint8Array { + const result = stripZeros(BigNumber.from(value).toHexString()) + if (result.length > 32) { + throw new Error(`invalid length for ${name}`) + } + return result +} + +function handleNumber(value: string): BigNumber { + if (value === '0x') { + return Zero + } + return BigNumber.from(value) +} + +function handleAddress(value: string): string { + if (value === '0x') { + // @ts-ignore + return null + } + return getAddress(value) +} + +export enum SourceHashDomain { + UserDeposit = 0, + L1InfoDeposit = 1, +} + +interface DepositTxOpts { + sourceHash?: string + from: string + to: string | null + mint: BigNumberish + value: BigNumberish + gas: BigNumberish + data: string + domain?: SourceHashDomain + l1BlockHash?: string + logIndex?: BigNumberish + sequenceNumber?: BigNumberish +} + +interface DepostTxExtraOpts { + domain?: SourceHashDomain + l1BlockHash?: string + logIndex?: BigNumberish + sequenceNumber?: BigNumberish +} + +export class DepositTx { + public type = '0x7E' + private _sourceHash?: string + public from: string + public to: string | null + public mint: BigNumberish + public value: BigNumberish + public gas: BigNumberish + public data: BigNumberish + + public domain?: SourceHashDomain + public l1BlockHash?: string + public logIndex?: BigNumberish + public sequenceNumber?: BigNumberish + + constructor(opts: Partial = {}) { + this._sourceHash = opts.sourceHash + this.from = opts.from! + this.to = opts.to! + this.mint = opts.mint! + this.value = opts.value! + this.gas = opts.gas! + this.data = opts.data! + this.domain = opts.domain + this.l1BlockHash = opts.l1BlockHash + this.logIndex = opts.logIndex + this.sequenceNumber = opts.sequenceNumber + } + + hash() { + const encoded = this.encode() + return keccak256(encoded) + } + + sourceHash() { + if (!this._sourceHash) { + let marker: string + switch (this.domain) { + case SourceHashDomain.UserDeposit: + marker = BigNumber.from(this.logIndex).toHexString() + break + case SourceHashDomain.L1InfoDeposit: + marker = BigNumber.from(this.sequenceNumber).toHexString() + break + default: + throw new Error(`Unknown domain: ${this.domain}`) + } + + if (!this.l1BlockHash) { + throw new Error('Need l1BlockHash to compute sourceHash') + } + + const l1BlockHash = this.l1BlockHash + const input = hexConcat([l1BlockHash, zeroPad(marker, 32)]) + const depositIDHash = keccak256(input) + const domain = BigNumber.from(this.domain).toHexString() + const domainInput = hexConcat([zeroPad(domain, 32), depositIDHash]) + this._sourceHash = keccak256(domainInput) + } + return this._sourceHash + } + + encode() { + const fields: any = [ + this.sourceHash() || '0x', + getAddress(this.from) || '0x', + this.to != null ? getAddress(this.to) : '0x', + formatNumber(this.mint || 0, 'mint'), + formatNumber(this.value || 0, 'value'), + formatNumber(this.gas || 0, 'gas'), + this.data || '0x', + ] + + return hexConcat([this.type, RLP.encode(fields)]) + } + + decode(raw: BytesLike, extra: DepostTxExtraOpts = {}) { + const payload = arrayify(raw) + const transaction = RLP.decode(payload.slice(1)) + + this._sourceHash = transaction[0] + this.from = handleAddress(transaction[1]) + this.to = handleAddress(transaction[2]) + this.mint = handleNumber(transaction[3]) + this.value = handleNumber(transaction[4]) + this.gas = handleNumber(transaction[5]) + this.data = transaction[6] + + if ('l1BlockHash' in extra) { + this.l1BlockHash = extra.l1BlockHash + } + if ('domain' in extra) { + this.domain = extra.domain + } + if ('logIndex' in extra) { + this.logIndex = extra.logIndex + } + if ('sequenceNumber' in extra) { + this.sequenceNumber = extra.sequenceNumber + } + return this + } + + static decode(raw: BytesLike, extra?: DepostTxExtraOpts): DepositTx { + return new this().decode(raw, extra) + } + + fromL1Receipt(receipt: ContractReceipt, index: number): DepositTx { + if (!receipt.events) throw new Error('cannot parse receipt') + const event = receipt.events[index] + if (!event) { + throw new Error(`event index ${index} does not exist`) + } + return this.fromL1Event(event) + } + + static fromL1Receipt(receipt: ContractReceipt, index: number): DepositTx { + return new this({}).fromL1Receipt(receipt, index) + } + + fromL1Event(event: Event): DepositTx { + if (event.event !== 'TransactionDeposited') + throw new Error(`incorrect event type: ${event.event}`) + if (typeof event.args === 'undefined') throw new Error('no event args') + if (typeof event.args.from === 'undefined') + throw new Error('"from" undefined') + this.from = event.args.from + if (typeof event.args.isCreation === 'undefined') + throw new Error('"isCreation" undefined') + if (typeof event.args.to === 'undefined') throw new Error('"to" undefined') + this.to = event.args.isCreation ? null : event.args.to + if (typeof event.args.mint === 'undefined') + throw new Error('"mint" undefined') + this.mint = event.args.mint + if (typeof event.args.value === 'undefined') + throw new Error('"value" undefined') + this.value = event.args.value + if (typeof event.args.gasLimit === 'undefined') + throw new Error('"gasLimit" undefined') + this.gas = event.args.gasLimit + if (typeof event.args.data === 'undefined') + throw new Error('"data" undefined') + this.data = event.args.data + this.domain = SourceHashDomain.UserDeposit + this.l1BlockHash = event.blockHash + this.logIndex = event.logIndex + return this + } + + static fromL1Event(event: Event): DepositTx { + return new this({}).fromL1Event(event) + } +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index a74512a71bfb..39c775d64631 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -5,6 +5,11 @@ "main": "index.js", "license": "MIT", "dependencies": { + "@ethersproject/address": "^5.6.0", + "@ethersproject/bignumber": "^5.6.0", + "@ethersproject/bytes": "^5.6.1", + "@ethersproject/keccak256": "^5.6.0", + "@ethersproject/rlp": "^5.6.0", "hardhat": "^2.7.1", "rlp": "^2.2.7" }, diff --git a/packages/contracts/tasks/deposits.ts b/packages/contracts/tasks/deposits.ts index 7d194b685beb..eb7306a971b1 100644 --- a/packages/contracts/tasks/deposits.ts +++ b/packages/contracts/tasks/deposits.ts @@ -1,9 +1,15 @@ import { task, types } from 'hardhat/config' import { Contract, providers, utils, Wallet } from 'ethers' +import { Event } from '@ethersproject/contracts' import dotenv from 'dotenv' +import { DepositTx, SourceHashDomain } from '../helpers/index' dotenv.config() +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + task('deposit', 'Deposits funds onto L2.') .addParam( 'l1ProviderUrl', @@ -11,6 +17,12 @@ task('deposit', 'Deposits funds onto L2.') 'http://localhost:8545', types.string ) + .addParam( + 'l2ProviderUrl', + 'L2 provider URL.', + 'http://localhost:9545', + types.string + ) .addParam('to', 'Recipient address.', null, types.string) .addParam('amountEth', 'Amount in ETH to send.', null, types.string) .addOptionalParam( @@ -26,11 +38,18 @@ task('deposit', 'Deposits funds onto L2.') types.string ) .setAction(async (args) => { - const { l1ProviderUrl, to, amountEth, depositContractAddr, privateKey } = - args + const { + l1ProviderUrl, + l2ProviderUrl, + to, + amountEth, + depositContractAddr, + privateKey, + } = args const depositFeedArtifact = require('../artifacts/contracts/L1/DepositFeed.sol/DepositFeed.json') const l1Provider = new providers.JsonRpcProvider(l1ProviderUrl) + const l2Provider = new providers.JsonRpcProvider(l2ProviderUrl) let l1Wallet: Wallet | providers.JsonRpcSigner if (privateKey) { @@ -52,7 +71,8 @@ task('deposit', 'Deposits funds onto L2.') ).connect(l1Wallet) const amountWei = utils.parseEther(amountEth) - console.log(`Depositing ${amountEth} ETH to ${to}...`) + const value = amountWei.add(utils.parseEther('0.01')) + console.log(`Depositing ${amountEth} ETH to ${to}`) // Below adds 0.01 ETH to account for gas. const tx = await depositFeed.depositTransaction( to, @@ -60,11 +80,26 @@ task('deposit', 'Deposits funds onto L2.') '3000000', false, [], - { - value: amountWei.add(utils.parseEther('0.01')), - } + { value } ) console.log(`Got TX hash ${tx.hash}. Waiting...`) - await tx.wait() - console.log('Done.') + const receipt = await tx.wait() + + // find the transaction deposited event and derive + // the deposit transaction from it + const event = receipt.events.find( + (e: Event) => e.event === 'TransactionDeposited' + ) + const l2tx = DepositTx.fromL1Event(event) + const hash = l2tx.hash() + console.log(`Waiting for L2 TX hash ${hash}`) + + while (true) { + const tx = await l2Provider.send('eth_getTransactionByHash', [hash]) + if (tx) { + console.log('Deposit success') + break + } + await sleep(500) + } }) diff --git a/packages/contracts/test/helpers.spec.ts b/packages/contracts/test/helpers.spec.ts new file mode 100644 index 000000000000..a8e0f5f1a843 --- /dev/null +++ b/packages/contracts/test/helpers.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai' +import { DepositTx, SourceHashDomain } from '../helpers' +import { BigNumber } from '@ethersproject/bignumber' + +describe('Helpers', () => { + describe('DepositTx', () => { + it('should serialize/deserialize and hash', () => { + // constants serialized using optimistic-geth + // TODO(tynes): more tests + const hash = + '0xf5f97d03e8be48a4b20ed70c9d8b11f1c851bf949bf602b7580985705bb09077' + const raw = + '0x7ef862a077fc5994647d128a4d131d273a5e89e0306aac472494068a4f1fceab83dd073594de3829a23df1479438622a08a116e8eb3f620bb594b7e390864a90b7b923c9f9310c6f98aafe43f707880e043da617250000880de0b6b3a7640000832dc6c080' + + const tx = new DepositTx({ + from: '0xDe3829A23DF1479438622a08a116E8Eb3f620BB5', + gas: '0x2dc6c0', + data: '0x', + to: '0xB7e390864a90b7b923C9f9310C6F98aafE43F707', + value: '0xde0b6b3a7640000', + domain: SourceHashDomain.UserDeposit, + l1BlockHash: + '0xd1a498e053451fc90bd8a597051a1039010c8e55e2659b940d3070b326e4f4c5', + logIndex: 0, + mint: '0xe043da617250000', + }) + + const sourceHash = tx.sourceHash() + expect(sourceHash).to.deep.eq( + '0x77fc5994647d128a4d131d273a5e89e0306aac472494068a4f1fceab83dd0735' + ) + + const encoded = tx.encode() + expect(encoded).to.deep.eq(raw) + const hashed = tx.hash() + expect(hashed).to.deep.eq(hash) + + const decoded = DepositTx.decode(raw, { + domain: SourceHashDomain.UserDeposit, + l1BlockHash: tx.l1BlockHash, + logIndex: tx.logIndex, + }) + expect(decoded.from).to.deep.eq(tx.from) + expect(decoded.gas).to.deep.eq(BigNumber.from(tx.gas)) + expect(decoded.data).to.deep.eq(tx.data) + expect(decoded.to).to.deep.eq(tx.to) + expect(decoded.value).to.deep.eq(BigNumber.from(tx.value)) + expect(decoded.domain).to.deep.eq(SourceHashDomain.UserDeposit) + expect(decoded.l1BlockHash).to.deep.eq(tx.l1BlockHash) + expect(decoded.logIndex).to.deep.eq(tx.logIndex) + expect(decoded.mint).to.deep.eq(BigNumber.from(tx.mint)) + }) + }) +}) diff --git a/packages/contracts/yarn.lock b/packages/contracts/yarn.lock index 416ab6d28565..0b8635d57e36 100644 --- a/packages/contracts/yarn.lock +++ b/packages/contracts/yarn.lock @@ -288,6 +288,17 @@ "@ethersproject/logger" "^5.5.0" "@ethersproject/rlp" "^5.5.0" +"@ethersproject/address@^5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.6.0.tgz#13c49836d73e7885fc148ad633afad729da25012" + integrity sha512-6nvhYXjbXsHPS+30sHZ+U4VMagFC/9zAk6Gd/h3S21YW4+yfb0WfRtaAIZ4kfM4rrVwqiy284LP0GtL5HXGLxQ== + dependencies: + "@ethersproject/bignumber" "^5.6.0" + "@ethersproject/bytes" "^5.6.0" + "@ethersproject/keccak256" "^5.6.0" + "@ethersproject/logger" "^5.6.0" + "@ethersproject/rlp" "^5.6.0" + "@ethersproject/base64@5.5.0", "@ethersproject/base64@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.5.0.tgz#881e8544e47ed976930836986e5eb8fab259c090" @@ -312,6 +323,15 @@ "@ethersproject/logger" "^5.5.0" bn.js "^4.11.9" +"@ethersproject/bignumber@^5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.6.0.tgz#116c81b075c57fa765a8f3822648cf718a8a0e26" + integrity sha512-VziMaXIUHQlHJmkv1dlcd6GY2PmT0khtAqaMctCIDogxkrarMzA9L94KN1NeXqqOfFD6r0sJT3vCTOFSmZ07DA== + dependencies: + "@ethersproject/bytes" "^5.6.0" + "@ethersproject/logger" "^5.6.0" + bn.js "^4.11.9" + "@ethersproject/bytes@5.5.0", "@ethersproject/bytes@>=5.0.0-beta.129", "@ethersproject/bytes@^5.0.4", "@ethersproject/bytes@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.5.0.tgz#cb11c526de657e7b45d2e0f0246fb3b9d29a601c" @@ -319,6 +339,13 @@ dependencies: "@ethersproject/logger" "^5.5.0" +"@ethersproject/bytes@^5.6.0", "@ethersproject/bytes@^5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.6.1.tgz#24f916e411f82a8a60412344bf4a813b917eefe7" + integrity sha512-NwQt7cKn5+ZE4uDn+X5RAXLp46E1chXoaMmrxAyA0rblpxz8t58lVkrHXoRIn0lz1joQElQ8410GqhTqMOwc6g== + dependencies: + "@ethersproject/logger" "^5.6.0" + "@ethersproject/constants@5.5.0", "@ethersproject/constants@>=5.0.0-beta.128", "@ethersproject/constants@^5.0.4", "@ethersproject/constants@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.5.0.tgz#d2a2cd7d94bd1d58377d1d66c4f53c9be4d0a45e" @@ -401,11 +428,24 @@ "@ethersproject/bytes" "^5.5.0" js-sha3 "0.8.0" +"@ethersproject/keccak256@^5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@ethersproject/keccak256/-/keccak256-5.6.0.tgz#fea4bb47dbf8f131c2e1774a1cecbfeb9d606459" + integrity sha512-tk56BJ96mdj/ksi7HWZVWGjCq0WVl/QvfhFQNeL8fxhBlGoP+L80uDCiQcpJPd+2XxkivS3lwRm3E0CXTfol0w== + dependencies: + "@ethersproject/bytes" "^5.6.0" + js-sha3 "0.8.0" + "@ethersproject/logger@5.5.0", "@ethersproject/logger@>=5.0.0-beta.129", "@ethersproject/logger@^5.0.5", "@ethersproject/logger@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.5.0.tgz#0c2caebeff98e10aefa5aef27d7441c7fd18cf5d" integrity sha512-rIY/6WPm7T8n3qS2vuHTUBPdXHl+rGxWxW5okDfo9J4Z0+gRRZT0msvUdIJkE4/HS29GUMziwGaaKO2bWONBrg== +"@ethersproject/logger@^5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.6.0.tgz#d7db1bfcc22fd2e4ab574cba0bb6ad779a9a3e7a" + integrity sha512-BiBWllUROH9w+P21RzoxJKzqoqpkyM1pRnEKG69bulE9TSQD8SAIvTQqIMZmmCO8pUNkgLP1wndX1gKghSpBmg== + "@ethersproject/networks@5.5.1", "@ethersproject/networks@^5.5.0": version "5.5.1" resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.1.tgz#b7f7b9fb88dec1ea48f739b7fb9621311aa8ce6c" @@ -469,6 +509,14 @@ "@ethersproject/bytes" "^5.5.0" "@ethersproject/logger" "^5.5.0" +"@ethersproject/rlp@^5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.6.0.tgz#55a7be01c6f5e64d6e6e7edb6061aa120962a717" + integrity sha512-dz9WR1xpcTL+9DtOT/aDO+YyxSSdO8YIS0jyZwHHSlAmnxA6cKU3TrTd4Xc/bHayctxTgGLYNuVVoiXE4tTq1g== + dependencies: + "@ethersproject/bytes" "^5.6.0" + "@ethersproject/logger" "^5.6.0" + "@ethersproject/sha2@5.5.0", "@ethersproject/sha2@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@ethersproject/sha2/-/sha2-5.5.0.tgz#a40a054c61f98fd9eee99af2c3cc6ff57ec24db7"