Commit 0bc630b7 authored by mergify[bot]'s avatar mergify[bot] Committed by GitHub

Merge branch 'develop' into inphi/eq-log

parents 10d72a0a b7f161c4
......@@ -346,6 +346,7 @@ func TestMigration(t *testing.T) {
NumConfirmations: 1,
ResubmissionTimeout: 5 * time.Second,
SafeAbortNonceTooLowCount: 3,
TxNotInMempoolTimeout: 2 * time.Minute,
},
LogConfig: oplog.CLIConfig{
Level: "info",
......@@ -371,6 +372,7 @@ func TestMigration(t *testing.T) {
NumConfirmations: 1,
ResubmissionTimeout: 3 * time.Second,
SafeAbortNonceTooLowCount: 3,
TxNotInMempoolTimeout: 2 * time.Minute,
},
LogConfig: oplog.CLIConfig{
Level: "info",
......
......@@ -580,6 +580,7 @@ func (cfg SystemConfig) Start(_opts ...SystemConfigOption) (*System, error) {
ResubmissionTimeout: 3 * time.Second,
ReceiptQueryInterval: 50 * time.Millisecond,
NetworkTimeout: 2 * time.Second,
TxNotInMempoolTimeout: 2 * time.Minute,
},
AllowNonFinalized: cfg.NonFinalizedProposals,
LogConfig: oplog.CLIConfig{
......@@ -615,6 +616,7 @@ func (cfg SystemConfig) Start(_opts ...SystemConfigOption) (*System, error) {
ResubmissionTimeout: 3 * time.Second,
ReceiptQueryInterval: 50 * time.Millisecond,
NetworkTimeout: 2 * time.Second,
TxNotInMempoolTimeout: 2 * time.Minute,
},
LogConfig: oplog.CLIConfig{
Level: "info",
......
......@@ -28,6 +28,7 @@ const (
ResubmissionTimeoutFlagName = "resubmission-timeout"
NetworkTimeoutFlagName = "network-timeout"
TxSendTimeoutFlagName = "txmgr.send-timeout"
TxNotInMempoolTimeoutFlagName = "txmgr.not-in-mempool-timeout"
ReceiptQueryIntervalFlagName = "txmgr.receipt-query-interval"
)
......@@ -95,6 +96,12 @@ func CLIFlags(envPrefix string) []cli.Flag {
Value: 0,
EnvVar: opservice.PrefixEnvVar(envPrefix, "TXMGR_TX_SEND_TIMEOUT"),
},
cli.DurationFlag{
Name: TxNotInMempoolTimeoutFlagName,
Usage: "Timeout for aborting a tx send if the tx does not make it to the mempool.",
Value: 2 * time.Minute,
EnvVar: opservice.PrefixEnvVar(envPrefix, "TXMGR_TX_NOT_IN_MEMPOOL_TIMEOUT"),
},
cli.DurationFlag{
Name: ReceiptQueryIntervalFlagName,
Usage: "Frequency to poll for receipts",
......@@ -118,6 +125,7 @@ type CLIConfig struct {
ReceiptQueryInterval time.Duration
NetworkTimeout time.Duration
TxSendTimeout time.Duration
TxNotInMempoolTimeout time.Duration
}
func (m CLIConfig) Check() error {
......@@ -125,16 +133,22 @@ func (m CLIConfig) Check() error {
return errors.New("must provide a L1 RPC url")
}
if m.NumConfirmations == 0 {
return errors.New("num confirmations must not be 0")
return errors.New("NumConfirmations must not be 0")
}
if m.NetworkTimeout == 0 {
return errors.New("must provide a network timeout")
return errors.New("must provide NetworkTimeout")
}
if m.ResubmissionTimeout == 0 {
return errors.New("must provide a resumbission interval")
return errors.New("must provide ResubmissionTimeout")
}
if m.ReceiptQueryInterval == 0 {
return errors.New("must provide a receipt query interval")
return errors.New("must provide ReceiptQueryInterval")
}
if m.TxNotInMempoolTimeout == 0 {
return errors.New("must provide TxNotInMempoolTimeout")
}
if m.SafeAbortNonceTooLowCount == 0 {
return errors.New("SafeAbortNonceTooLowCount must not be 0")
}
if err := m.SignerCLIConfig.Check(); err != nil {
return err
......@@ -157,6 +171,7 @@ func ReadCLIConfig(ctx *cli.Context) CLIConfig {
ReceiptQueryInterval: ctx.GlobalDuration(ReceiptQueryIntervalFlagName),
NetworkTimeout: ctx.GlobalDuration(NetworkTimeoutFlagName),
TxSendTimeout: ctx.GlobalDuration(TxSendTimeoutFlagName),
TxNotInMempoolTimeout: ctx.GlobalDuration(TxNotInMempoolTimeoutFlagName),
}
}
......@@ -197,6 +212,7 @@ func NewConfig(cfg CLIConfig, l log.Logger) (Config, error) {
ResubmissionTimeout: cfg.ResubmissionTimeout,
ChainID: chainID,
TxSendTimeout: cfg.TxSendTimeout,
TxNotInMempoolTimeout: cfg.TxNotInMempoolTimeout,
NetworkTimeout: cfg.NetworkTimeout,
ReceiptQueryInterval: cfg.ReceiptQueryInterval,
NumConfirmations: cfg.NumConfirmations,
......@@ -222,6 +238,10 @@ type Config struct {
// By default it is unbounded. If set, this is recommended to be at least 20 minutes.
TxSendTimeout time.Duration
// TxNotInMempoolTimeout is how long to wait before aborting a transaction send if the transaction does not
// make it to the mempool. If the tx is in the mempool, TxSendTimeout is used instead.
TxNotInMempoolTimeout time.Duration
// NetworkTimeout is the allowed duration for a single network request.
// This is intended to be used for network requests that can be replayed.
NetworkTimeout time.Duration
......
......@@ -3,6 +3,7 @@ package txmgr
import (
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
......@@ -12,48 +13,53 @@ import (
// this context, a txn may correspond to multiple different txn hashes due to
// varying gas prices, though we treat them all as the same logical txn. This
// struct is primarily used to determine whether or not the txmgr should abort a
// given txn and retry with a higher nonce.
// given txn.
type SendState struct {
minedTxs map[common.Hash]struct{}
nonceTooLowCount uint64
mu sync.RWMutex
minedTxs map[common.Hash]struct{}
mu sync.RWMutex
now func() time.Time
safeAbortNonceTooLowCount uint64
// Config
nonceTooLowCount uint64
txInMempoolDeadline time.Time // deadline to abort at if no transactions are in the mempool
// Counts of the different types of errors
successFullPublishCount uint64 // nil error => tx made it to the mempool
safeAbortNonceTooLowCount uint64 // nonce too low error
}
// NewSendState parameterizes a new SendState from the passed
// safeAbortNonceTooLowCount.
func NewSendState(safeAbortNonceTooLowCount uint64) *SendState {
// NewSendStateWithNow creates a new send state with the provided clock.
func NewSendStateWithNow(safeAbortNonceTooLowCount uint64, unableToSendTimeout time.Duration, now func() time.Time) *SendState {
if safeAbortNonceTooLowCount == 0 {
panic("txmgr: safeAbortNonceTooLowCount cannot be zero")
}
return &SendState{
minedTxs: make(map[common.Hash]struct{}),
nonceTooLowCount: 0,
safeAbortNonceTooLowCount: safeAbortNonceTooLowCount,
txInMempoolDeadline: now().Add(unableToSendTimeout),
now: now,
}
}
// NewSendState creates a new send state
func NewSendState(safeAbortNonceTooLowCount uint64, unableToSendTimeout time.Duration) *SendState {
return NewSendStateWithNow(safeAbortNonceTooLowCount, unableToSendTimeout, time.Now)
}
// ProcessSendError should be invoked with the error returned for each
// publication. It is safe to call this method with nil or arbitrary errors.
// Currently it only acts on errors containing the ErrNonceTooLow message.
func (s *SendState) ProcessSendError(err error) {
// Nothing to do.
if err == nil {
return
}
// Only concerned with ErrNonceTooLow.
if !strings.Contains(err.Error(), core.ErrNonceTooLow.Error()) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// Record this nonce too low observation.
s.nonceTooLowCount++
// Record the type of error
switch {
case err == nil:
s.successFullPublishCount++
case strings.Contains(err.Error(), core.ErrNonceTooLow.Error()):
s.nonceTooLowCount++
}
}
// TxMined records that the txn with txnHash has been mined and is await
......@@ -85,8 +91,9 @@ func (s *SendState) TxNotMined(txHash common.Hash) {
}
// ShouldAbortImmediately returns true if the txmgr should give up on trying a
// given txn with the target nonce. For now, this only happens if we see an
// extended period of getting ErrNonceTooLow without having a txn mined.
// given txn with the target nonce.
// This occurs when the set of errors recorded indicates that no further progress can be made
// on this transaction.
func (s *SendState) ShouldAbortImmediately() bool {
s.mu.RLock()
defer s.mu.RUnlock()
......@@ -96,9 +103,14 @@ func (s *SendState) ShouldAbortImmediately() bool {
return false
}
// Only abort if we've observed enough ErrNonceTooLow to meet our safe abort
// threshold.
return s.nonceTooLowCount >= s.safeAbortNonceTooLowCount
// If we have exceeded the nonce too low count, abort
if s.nonceTooLowCount >= s.safeAbortNonceTooLowCount ||
// If we have not published a transaction in the allotted time, abort
(s.successFullPublishCount == 0 && s.now().After(s.txInMempoolDeadline)) {
return true
}
return false
}
// IsWaitingForConfirmation returns true if we have at least one confirmation on
......
......@@ -3,6 +3,7 @@ package txmgr_test
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
......@@ -11,14 +12,18 @@ import (
"github.com/ethereum/go-ethereum/core"
)
const testSafeAbortNonceTooLowCount = 3
var (
testHash = common.HexToHash("0x01")
)
const testSafeAbortNonceTooLowCount = 3
func newSendState() *txmgr.SendState {
return txmgr.NewSendState(testSafeAbortNonceTooLowCount)
return newSendStateWithTimeout(time.Hour, time.Now)
}
func newSendStateWithTimeout(t time.Duration, now func() time.Time) *txmgr.SendState {
return txmgr.NewSendStateWithNow(testSafeAbortNonceTooLowCount, t, now)
}
func processNSendErrors(sendState *txmgr.SendState, err error, n int) {
......@@ -160,3 +165,27 @@ func TestSendStateIsNotWaitingForConfirmationAfterTxUnmined(t *testing.T) {
sendState.TxNotMined(testHash)
require.False(t, sendState.IsWaitingForConfirmation())
}
func stepClock(step time.Duration) func() time.Time {
i := 0
return func() time.Time {
var start time.Time
i += 1
return start.Add(time.Duration(i) * step)
}
}
// TestSendStateTimeoutAbort ensure that this will abort if it passes the tx pool timeout
// when no successful transactions have been recorded
func TestSendStateTimeoutAbort(t *testing.T) {
sendState := newSendStateWithTimeout(10*time.Millisecond, stepClock(20*time.Millisecond))
require.True(t, sendState.ShouldAbortImmediately(), "Should abort after timing out")
}
// TestSendStateNoTimeoutAbortIfPublishedTx ensure that this will not abort if there is
// a successful transaction send.
func TestSendStateNoTimeoutAbortIfPublishedTx(t *testing.T) {
sendState := newSendStateWithTimeout(10*time.Millisecond, stepClock(20*time.Millisecond))
sendState.ProcessSendError(nil)
require.False(t, sendState.ShouldAbortImmediately(), "Should not abort if published transcation successfully")
}
......@@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"math/big"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/txpool"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
......@@ -120,9 +122,8 @@ type TxCandidate struct {
// invocation of sendTx returns (called with differing gas prices). The method
// may be canceled using the passed context.
//
// The initially supplied transaction must be signed, have gas estimation done, and have a reasonable gas fee.
// When the transaction is resubmitted the tx manager will re-sign the transaction at a different gas pricing
// but retain the gas used, the nonce, and the data.
// The transaction manager handles all signing. If and only if the gas limit is 0, the
// transaction manager will do a gas estimation.
//
// NOTE: Send should be called by AT MOST one caller at a time.
func (m *SimpleTxManager) Send(ctx context.Context, candidate TxCandidate) (*types.Receipt, error) {
......@@ -133,8 +134,7 @@ func (m *SimpleTxManager) Send(ctx context.Context, candidate TxCandidate) (*typ
}
tx, err := m.craftTx(ctx, candidate)
if err != nil {
m.l.Error("Failed to create the transaction", "err", err)
return nil, err
return nil, fmt.Errorf("failed to create the tx: %w", err)
}
return m.send(ctx, tx)
}
......@@ -147,7 +147,7 @@ func (m *SimpleTxManager) Send(ctx context.Context, candidate TxCandidate) (*typ
func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (*types.Transaction, error) {
gasTipCap, basefee, err := m.suggestGasPriceCaps(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get gas price info: %w", err)
}
gasFeeCap := calcGasFeeCap(basefee, gasTipCap)
......@@ -193,77 +193,22 @@ func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (*
return m.cfg.Signer(ctx, candidate.From, types.NewTx(rawTx))
}
// send submits the same transaction several times with increasing gas prices as necessary.
// It waits for the transaction to be confirmed on chain.
func (m *SimpleTxManager) send(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) {
// Initialize a wait group to track any spawned goroutines, and ensure
// we properly clean up any dangling resources this method generates.
// We assert that this is the case thoroughly in our unit tests.
var wg sync.WaitGroup
defer wg.Wait()
// Initialize a subcontext for the goroutines spawned in this process.
// The defer to cancel is done here (in reverse order of Wait) so that
// the goroutines can exit before blocking on the wait group.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
sendState := NewSendState(m.cfg.SafeAbortNonceTooLowCount)
// Create a closure that will block on submitting the tx in the
// background, returning the first successfully mined receipt back to
// the main event loop via receiptChan.
sendState := NewSendState(m.cfg.SafeAbortNonceTooLowCount, m.cfg.TxNotInMempoolTimeout)
receiptChan := make(chan *types.Receipt, 1)
sendTxAsync := func(tx *types.Transaction) {
defer wg.Done()
txHash := tx.Hash()
nonce := tx.Nonce()
gasTipCap := tx.GasTipCap()
gasFeeCap := tx.GasFeeCap()
log := m.l.New("txHash", txHash, "nonce", nonce, "gasTipCap", gasTipCap, "gasFeeCap", gasFeeCap)
log.Info("publishing transaction")
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
err := m.backend.SendTransaction(cCtx, tx)
sendState.ProcessSendError(err)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
if errors.Is(err, txpool.ErrAlreadyKnown) {
log.Info("resubmitted already known transaction")
return
}
log.Error("unable to publish transaction", "err", err)
if sendState.ShouldAbortImmediately() {
log.Warn("Aborting transaction submission")
cancel()
}
return
}
log.Info("transaction published successfully")
// Wait for the transaction to be mined, reporting the receipt
// back to the main event loop if found.
receipt, err := m.waitMined(ctx, tx, sendState)
if err != nil {
log.Debug("send tx failed", "err", err)
}
if receipt != nil {
// Use non-blocking select to ensure function can exit
// if more than one receipt is discovered.
select {
case receiptChan <- receipt:
log.Trace("send tx succeeded")
default:
}
}
m.publishAndWaitForTx(ctx, tx, sendState, receiptChan)
}
// Submit and wait for the receipt at our first gas price in the
// background, before entering the event loop and waiting out the
// resubmission timeout.
// Immediately publish a transaction before starting the resumbission loop
wg.Add(1)
go sendTxAsync(tx)
......@@ -272,124 +217,137 @@ func (m *SimpleTxManager) send(ctx context.Context, tx *types.Transaction) (*typ
for {
select {
// Whenever a resubmission timeout has elapsed, bump the gas
// price and publish a new transaction.
case <-ticker.C:
// Avoid republishing if we are waiting for confirmation on an
// existing tx. This is primarily an optimization to reduce the
// number of API calls we make, but also reduces the chances of
// getting a false positive reading for ShouldAbortImmediately.
// Don't resubmit a transaction if it has been mined, but we are waiting for the conf depth.
if sendState.IsWaitingForConfirmation() {
continue
}
// Increase the gas price & submit the new transaction
newTx, err := m.increaseGasPrice(ctx, tx)
if err != nil {
m.l.Error("Failed to increase the gas price for the tx", "err", err)
// Don't `continue` here so we resubmit the transaction with the same gas price.
} else {
// Save the tx so we know it's gas price.
tx = newTx
// If we see lots of unrecoverable errors (and no pending transactions) abort sending the transaction.
if sendState.ShouldAbortImmediately() {
m.l.Warn("Aborting transaction submission")
return nil, errors.New("aborted transaction sending")
}
// Increase the gas price & submit the new transaction
tx = m.increaseGasPrice(ctx, tx)
wg.Add(1)
go sendTxAsync(tx)
// The passed context has been canceled, i.e. in the event of a
// shutdown.
case <-ctx.Done():
return nil, ctx.Err()
// The transaction has confirmed.
case receipt := <-receiptChan:
return receipt, nil
}
}
}
// waitMined implements the core functionality of WaitMined, with the option to
// pass in a SendState to record whether or not the transaction is mined.
func (m *SimpleTxManager) waitMined(ctx context.Context, tx *types.Transaction, sendState *SendState) (*types.Receipt, error) {
queryTicker := time.NewTicker(m.cfg.ReceiptQueryInterval)
defer queryTicker.Stop()
// publishAndWaitForTx publishes the transaction to the transaction pool and then waits for it with [waitMined].
// It should be called in a new go-routine. It will send the receipt to receiptChan in a non-blocking way if a receipt is found
// for the transaction.
func (m *SimpleTxManager) publishAndWaitForTx(ctx context.Context, tx *types.Transaction, sendState *SendState, receiptChan chan *types.Receipt) {
log := m.l.New("hash", tx.Hash(), "nonce", tx.Nonce(), "gasTipCap", tx.GasTipCap(), "gasFeeCap", tx.GasFeeCap())
log.Info("publishing transaction")
txHash := tx.Hash()
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
err := m.backend.SendTransaction(cCtx, tx)
sendState.ProcessSendError(err)
for {
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
receipt, err := m.backend.TransactionReceipt(cCtx, txHash)
cancel()
// Properly log & exit if there is an error
if err != nil {
switch {
case receipt != nil:
if sendState != nil {
sendState.TxMined(txHash)
}
txHeight := receipt.BlockNumber.Uint64()
tipHeight, err := m.backend.BlockNumber(ctx)
if err != nil {
m.l.Error("Unable to fetch block number", "err", err)
break
}
m.l.Debug("Transaction mined, checking confirmations", "txHash", txHash, "txHeight", txHeight,
"tipHeight", tipHeight, "numConfirmations", m.cfg.NumConfirmations)
// The transaction is considered confirmed when
// txHeight+numConfirmations-1 <= tipHeight. Note that the -1 is
// needed to account for the fact that confirmations have an
// inherent off-by-one, i.e. when using 1 confirmation the
// transaction should be confirmed when txHeight is equal to
// tipHeight. The equation is rewritten in this form to avoid
// underflows.
if txHeight+m.cfg.NumConfirmations <= tipHeight+1 {
m.l.Info("Transaction confirmed", "txHash", txHash)
return receipt, nil
}
// Safe to subtract since we know the LHS above is greater.
confsRemaining := (txHeight + m.cfg.NumConfirmations) - (tipHeight + 1)
m.l.Debug("Transaction not yet confirmed", "txHash", txHash, "confsRemaining", confsRemaining)
case err != nil:
m.l.Trace("Receipt retrievel failed", "hash", txHash, "err", err)
case errStringMatch(err, core.ErrNonceTooLow):
log.Warn("nonce too low", "err", err)
case errStringMatch(err, context.Canceled):
log.Warn("transaction send cancelled", "err", err)
case errStringMatch(err, txpool.ErrAlreadyKnown):
log.Warn("resubmitted already known transaction", "err", err)
case errStringMatch(err, txpool.ErrReplaceUnderpriced):
log.Warn("transaction replacement is underpriced", "err", err)
case errStringMatch(err, txpool.ErrUnderpriced):
log.Warn("transaction is underpriced", "err", err)
default:
if sendState != nil {
sendState.TxNotMined(txHash)
}
m.l.Trace("Transaction not yet mined", "hash", txHash)
log.Error("unable to publish transaction", "err", err)
}
return
}
log.Info("Transaction successfully published")
// Poll for the transaction to be ready & then send the result to receiptChan
receipt, err := m.waitMined(ctx, tx, sendState)
if err != nil {
log.Warn("Transaction receipt not found", "err", err)
return
}
select {
case receiptChan <- receipt:
default:
}
}
// waitMined waits for the transaction to be mined or for the context to be cancelled.
func (m *SimpleTxManager) waitMined(ctx context.Context, tx *types.Transaction, sendState *SendState) (*types.Receipt, error) {
txHash := tx.Hash()
queryTicker := time.NewTicker(m.cfg.ReceiptQueryInterval)
defer queryTicker.Stop()
for {
select {
case <-ctx.Done():
m.l.Warn("context cancelled in waitMined")
return nil, ctx.Err()
case <-queryTicker.C:
if receipt := m.queryReceipt(ctx, txHash, sendState); receipt != nil {
return receipt, nil
}
}
}
}
// suggestGasPriceCaps suggests what the new tip & new basefee should be based on the current L1 conditions
func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, error) {
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
// queryReceipt queries for the receipt and returns the receipt if it has passed the confirmation depth
func (m *SimpleTxManager) queryReceipt(ctx context.Context, txHash common.Hash, sendState *SendState) *types.Receipt {
ctx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
tip, err := m.backend.SuggestGasTipCap(cCtx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch the suggested gas tip cap: %w", err)
} else if tip == nil {
return nil, nil, errors.New("the suggested tip was nil")
receipt, err := m.backend.TransactionReceipt(ctx, txHash)
if errors.Is(err, ethereum.NotFound) {
sendState.TxNotMined(txHash)
m.l.Trace("Transaction not yet mined", "hash", txHash)
return nil
} else if err != nil {
m.l.Info("Receipt retrieval failed", "hash", txHash, "err", err)
return nil
} else if receipt == nil {
m.l.Warn("Receipt and error are both nil", "hash", txHash)
return nil
}
cCtx, cancel = context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
head, err := m.backend.HeaderByNumber(cCtx, nil)
// Receipt is confirmed to be valid from this point on
sendState.TxMined(txHash)
txHeight := receipt.BlockNumber.Uint64()
tipHeight, err := m.backend.BlockNumber(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch the suggested basefee: %w", err)
} else if head.BaseFee == nil {
return nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a basefee")
m.l.Error("Unable to fetch block number", "err", err)
return nil
}
return tip, head.BaseFee, nil
m.l.Debug("Transaction mined, checking confirmations", "hash", txHash, "txHeight", txHeight,
"tipHeight", tipHeight, "numConfirmations", m.cfg.NumConfirmations)
// The transaction is considered confirmed when
// txHeight+numConfirmations-1 <= tipHeight. Note that the -1 is
// needed to account for the fact that confirmations have an
// inherent off-by-one, i.e. when using 1 confirmation the
// transaction should be confirmed when txHeight is equal to
// tipHeight. The equation is rewritten in this form to avoid
// underflows.
if txHeight+m.cfg.NumConfirmations <= tipHeight+1 {
m.l.Info("Transaction confirmed", "hash", txHash)
return receipt
}
// Safe to subtract since we know the LHS above is greater.
confsRemaining := (txHeight + m.cfg.NumConfirmations) - (tipHeight + 1)
m.l.Debug("Transaction not yet confirmed", "hash", txHash, "confsRemaining", confsRemaining)
return nil
}
// increaseGasPrice takes the previous transaction & potentially clones then signs it with a higher tip.
......@@ -399,15 +357,18 @@ func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *b
//
// We do not re-estimate the amount of gas used because for some stateful transactions (like output proposals) the
// act of including the transaction renders the repeat of the transaction invalid.
func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) {
//
// If it encounters an error with creating the new transaction, it will return the old transaction.
func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transaction) *types.Transaction {
tip, basefee, err := m.suggestGasPriceCaps(ctx)
if err != nil {
return nil, err
m.l.Warn("failed to get suggested gas tip and basefee", "err", err)
return tx
}
gasTipCap, gasFeeCap := updateFees(tx.GasTipCap(), tx.GasFeeCap(), tip, basefee, m.l)
if tx.GasTipCapIntCmp(gasTipCap) == 0 && tx.GasFeeCapIntCmp(gasFeeCap) == 0 {
return tx, nil
return tx
}
rawTx := &types.DynamicFeeTx{
......@@ -423,7 +384,33 @@ func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transa
}
ctx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
return m.cfg.Signer(ctx, m.cfg.From, types.NewTx(rawTx))
newTx, err := m.cfg.Signer(ctx, m.cfg.From, types.NewTx(rawTx))
if err != nil {
m.l.Warn("failed to sign new transaction", "err", err)
return tx
}
return newTx
}
// suggestGasPriceCaps suggests what the new tip & new basefee should be based on the current L1 conditions
func (m *SimpleTxManager) suggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, error) {
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
tip, err := m.backend.SuggestGasTipCap(cCtx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch the suggested gas tip cap: %w", err)
} else if tip == nil {
return nil, nil, errors.New("the suggested tip was nil")
}
cCtx, cancel = context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel()
head, err := m.backend.HeaderByNumber(cCtx, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch the suggested basefee: %w", err)
} else if head.BaseFee == nil {
return nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a basefee")
}
return tip, head.BaseFee, nil
}
// calcThresholdValue returns x * priceBumpPercent / 100
......@@ -478,3 +465,14 @@ func calcGasFeeCap(baseFee, gasTipCap *big.Int) *big.Int {
new(big.Int).Mul(baseFee, big.NewInt(2)),
)
}
// errStringMatch returns true if err.Error() is a substring in target.Error() or if both are nil.
// It can accept nil errors without issue.
func errStringMatch(err, target error) bool {
if err == nil && target == nil {
return true
} else if err == nil || target == nil {
return false
}
return strings.Contains(err.Error(), target.Error())
}
......@@ -3,6 +3,7 @@ package txmgr
import (
"context"
"errors"
"fmt"
"math/big"
"sync"
"testing"
......@@ -20,6 +21,10 @@ import (
type sendTransactionFunc func(ctx context.Context, tx *types.Transaction) error
func testSendState() *SendState {
return NewSendState(100, time.Hour)
}
// testHarness houses the necessary resources to test the SimpleTxManager.
type testHarness struct {
cfg Config
......@@ -68,6 +73,7 @@ func configWithNumConfs(numConfirmations uint64) Config {
ReceiptQueryInterval: 50 * time.Millisecond,
NumConfirmations: numConfirmations,
SafeAbortNonceTooLowCount: 3,
TxNotInMempoolTimeout: 1 * time.Hour,
Signer: func(ctx context.Context, from common.Address, tx *types.Transaction) (*types.Transaction, error) {
return tx, nil
},
......@@ -530,7 +536,7 @@ func TestWaitMinedReturnsReceiptOnFirstSuccess(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
receipt, err := h.mgr.waitMined(ctx, tx, nil)
receipt, err := h.mgr.waitMined(ctx, tx, testSendState())
require.Nil(t, err)
require.NotNil(t, receipt)
require.Equal(t, receipt.TxHash, txHash)
......@@ -549,7 +555,7 @@ func TestWaitMinedCanBeCanceled(t *testing.T) {
// Create an unimined tx.
tx := types.NewTx(&types.LegacyTx{})
receipt, err := h.mgr.waitMined(ctx, tx, nil)
receipt, err := h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour))
require.Equal(t, err, context.DeadlineExceeded)
require.Nil(t, receipt)
}
......@@ -570,7 +576,7 @@ func TestWaitMinedMultipleConfs(t *testing.T) {
txHash := tx.Hash()
h.backend.mine(&txHash, new(big.Int))
receipt, err := h.mgr.waitMined(ctx, tx, nil)
receipt, err := h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour))
require.Equal(t, err, context.DeadlineExceeded)
require.Nil(t, receipt)
......@@ -579,7 +585,7 @@ func TestWaitMinedMultipleConfs(t *testing.T) {
// Mine an empty block, tx should now be confirmed.
h.backend.mine(nil, nil)
receipt, err = h.mgr.waitMined(ctx, tx, nil)
receipt, err = h.mgr.waitMined(ctx, tx, NewSendState(10, time.Hour))
require.Nil(t, err)
require.NotNil(t, receipt)
require.Equal(t, txHash, receipt.TxHash)
......@@ -692,7 +698,7 @@ func TestWaitMinedReturnsReceiptAfterFailure(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
receipt, err := mgr.waitMined(ctx, tx, nil)
receipt, err := mgr.waitMined(ctx, tx, testSendState())
require.Nil(t, err)
require.NotNil(t, receipt)
require.Equal(t, receipt.TxHash, txHash)
......@@ -724,8 +730,7 @@ func doGasPriceIncrease(t *testing.T, txTipCap, txFeeCap, newTip, newBaseFee int
GasTipCap: big.NewInt(txTipCap),
GasFeeCap: big.NewInt(txFeeCap),
})
newTx, err := mgr.increaseGasPrice(context.Background(), tx)
require.NoError(t, err)
newTx := mgr.increaseGasPrice(context.Background(), tx)
return tx, newTx
}
......@@ -831,11 +836,32 @@ func TestIncreaseGasPriceNotExponential(t *testing.T) {
// Run IncreaseGasPrice a bunch of times in a row to simulate a very fast resubmit loop.
for i := 0; i < 20; i++ {
ctx := context.Background()
newTx, err := mgr.increaseGasPrice(ctx, tx)
require.NoError(t, err)
newTx := mgr.increaseGasPrice(ctx, tx)
require.True(t, newTx.GasFeeCap().Cmp(feeCap) == 0, "new tx fee cap must be equal L1")
require.True(t, newTx.GasTipCap().Cmp(borkedBackend.gasTip) == 0, "new tx tip must be equal L1")
tx = newTx
}
}
func TestErrStringMatch(t *testing.T) {
tests := []struct {
err error
target error
match bool
}{
{err: nil, target: nil, match: true},
{err: errors.New("exists"), target: nil, match: false},
{err: nil, target: errors.New("exists"), match: false},
{err: errors.New("exact match"), target: errors.New("exact match"), match: true},
{err: errors.New("partial: match"), target: errors.New("match"), match: true},
}
for i, test := range tests {
i := i
test := test
t.Run(fmt.Sprint(i), func(t *testing.T) {
require.Equal(t, test.match, errStringMatch(test.err, test.target))
})
}
}
......@@ -123,7 +123,6 @@ services:
OP_BATCHER_L1_ETH_RPC: http://l1:8545
OP_BATCHER_L2_ETH_RPC: http://l2:8545
OP_BATCHER_ROLLUP_RPC: http://op-node:8545
TX_MANAGER_TIMEOUT: 10m
OFFLINE_GAS_ESTIMATION: false
OP_BATCHER_MAX_CHANNEL_DURATION: 1
OP_BATCHER_MAX_L1_TX_SIZE_BYTES: 120000
......
......@@ -92,8 +92,10 @@ yarn echidna:aliasing
#### Configuration
1. Create or modify a file `<network-name>.json` inside of the [`deploy-config`](./deploy-config/) folder.
1. Create or modify a file `<network-name>.ts` inside of the [`deploy-config`](./deploy-config/) folder.
2. Fill out this file according to the `deployConfigSpec` located inside of the [`hardhat.config.ts](./hardhat.config.ts)
3. Optionally: Run `npx hardhat generate-deploy-config --network <network-name>` to generate the associated JSON
file. This is required if using `op-chain-ops`.
#### Execution
......
{
"finalSystemOwner": "0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A",
"controller": "0x78339d822c23d943e4a2d4c3dd5408f66e6d662d",
"portalGuardian": "0x78339d822c23d943e4a2d4c3dd5408f66e6d662d",
"l1StartingBlockTag": "0x126e52a0cc0ae18948f567ee9443f4a8f0db67c437706e35baee424eb314a0d0",
"l1ChainID": 1,
"l2ChainID": 10,
"l2BlockTime": 2,
"maxSequencerDrift": 600,
"sequencerWindowSize": 3600,
"channelTimeout": 300,
"p2pSequencerAddress": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
"batchInboxAddress": "0xff00000000000000000000000000000000000010",
"batchSenderAddress": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"l2OutputOracleSubmissionInterval": 20,
"l2OutputOracleStartingTimestamp": 1679069195,
"l2OutputOracleStartingBlockNumber": 79149704,
"l2OutputOracleProposer": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"l2OutputOracleChallenger": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"finalizationPeriodSeconds": 2,
"proxyAdminOwner": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"baseFeeVaultRecipient": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"l1FeeVaultRecipient": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"sequencerFeeVaultRecipient": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"governanceTokenName": "Optimism",
"governanceTokenSymbol": "OP",
"governanceTokenOwner": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
"l2GenesisBlockGasLimit": "0x17D7840",
"l2GenesisBlockCoinbase": "0x4200000000000000000000000000000000000011",
"l2GenesisBlockBaseFeePerGas": "0x3b9aca00",
"gasPriceOracleOverhead": 2100,
"gasPriceOracleScalar": 1000000,
"eip1559Denominator": 50,
"eip1559Elasticity": 10,
"l2GenesisRegolithTimeOffset": "0x0"
}
\ No newline at end of file
import { DeployConfig } from '../src/deploy-config'
// NOTE: The 'mainnet' network is currently being used for bedrock migration rehearsals.
// The system configured below is not yet live on mainnet, and many of the addresses used are
// unsafe for a production system.
const config: DeployConfig = {
finalSystemOwner: '0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A',
controller: '0x78339d822c23d943e4a2d4c3dd5408f66e6d662d',
portalGuardian: '0x78339d822c23d943e4a2d4c3dd5408f66e6d662d',
l1StartingBlockTag:
'0x126e52a0cc0ae18948f567ee9443f4a8f0db67c437706e35baee424eb314a0d0',
l1ChainID: 1,
l2ChainID: 10,
l2BlockTime: 2,
maxSequencerDrift: 600,
sequencerWindowSize: 3600,
channelTimeout: 300,
p2pSequencerAddress: '0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65',
batchInboxAddress: '0xff00000000000000000000000000000000000010',
batchSenderAddress: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
l2OutputOracleSubmissionInterval: 20,
l2OutputOracleStartingTimestamp: 1679069195,
l2OutputOracleStartingBlockNumber: 79149704,
l2OutputOracleProposer: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC',
l2OutputOracleChallenger: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC',
finalizationPeriodSeconds: 2,
proxyAdminOwner: '0x90F79bf6EB2c4f870365E785982E1f101E93b906',
baseFeeVaultRecipient: '0x90F79bf6EB2c4f870365E785982E1f101E93b906',
l1FeeVaultRecipient: '0x90F79bf6EB2c4f870365E785982E1f101E93b906',
sequencerFeeVaultRecipient: '0x90F79bf6EB2c4f870365E785982E1f101E93b906',
governanceTokenName: 'Optimism',
governanceTokenSymbol: 'OP',
governanceTokenOwner: '0x90F79bf6EB2c4f870365E785982E1f101E93b906',
l2GenesisBlockGasLimit: '0x17D7840',
l2GenesisBlockCoinbase: '0x4200000000000000000000000000000000000011',
l2GenesisBlockBaseFeePerGas: '0x3b9aca00',
gasPriceOracleOverhead: 2100,
gasPriceOracleScalar: 1000000,
eip1559Denominator: 50,
eip1559Elasticity: 10,
l2GenesisRegolithTimeOffset: '0x0',
}
export default config
......@@ -22,8 +22,10 @@ const config: HardhatUserConfig = {
hardhat: {
live: false,
},
// NOTE: The 'mainnet' network is currently being used for mainnet rehearsals.
mainnet: {
url: process.env.L1_RPC || 'http://localhost:8545',
url: process.env.L1_RPC || 'https://mainnet-l1-rehearsal.optimism.io',
accounts: [process.env.PRIVATE_KEY_DEPLOYER || ethers.constants.HashZero],
},
devnetL1: {
live: false,
......
......@@ -129,6 +129,26 @@ interface RequiredDeployConfig {
* Output finalization period in seconds.
*/
finalizationPeriodSeconds: number
/**
* Owner of the ProxyAdmin contract.
*/
proxyAdminOwner: string
/**
* L1 address which receives the base fee for the L2 network.
*/
baseFeeVaultRecipient: string
/**
* L1 address which receives data fees for the L2 network.
*/
l1FeeVaultRecipient: string
/**
* L1 address which receives tip fees for the L2 network.
*/
sequencerFeeVaultRecipient: string
}
/**
......@@ -160,6 +180,10 @@ interface OptionalL2DeployConfig {
l2GenesisBlockGasUsed: string
l2GenesisBlockParentHash: string
l2GenesisBlockBaseFeePerGas: string
l2GenesisBlockCoinbase: string
l2GenesisRegolithTimeOffset: string
eip1559Denominator: number
eip1559Elasticity: number
gasPriceOracleOverhead: number
gasPriceOracleScalar: number
}
......@@ -243,6 +267,18 @@ export const deployConfigSpec: {
type: 'number',
default: 2,
},
proxyAdminOwner: {
type: 'address',
},
baseFeeVaultRecipient: {
type: 'address',
},
l1FeeVaultRecipient: {
type: 'address',
},
sequencerFeeVaultRecipient: {
type: 'address',
},
cliqueSignerAddress: {
type: 'address',
default: ethers.constants.AddressZero,
......
import fs from 'fs'
import path from 'path'
import { task } from 'hardhat/config'
import { HardhatRuntimeEnvironment } from 'hardhat/types'
task(
'generate-deploy-config',
'generates a json config file for the current network'
).setAction(async ({}, hre: HardhatRuntimeEnvironment) => {
try {
const base = path.join(hre.config.paths.deployConfig, hre.network.name)
if (fs.existsSync(`${base}.ts`)) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const config = require(`${base}.ts`).default
fs.writeFileSync(`${base}.json`, JSON.stringify(config, null, 2), 'utf8')
} else {
throw new Error('not found')
}
} catch (err) {
throw new Error(
`error while loading deploy config for network: ${hre.network.name}, ${err}`
)
}
})
......@@ -10,3 +10,4 @@ import './check-l2'
import './update-dynamic-oracle-config'
import './wait-for-final-batch'
import './wait-for-final-deposit'
import './generate-deploy-config'
//SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
/* Testing utilities */
import { Test } from "forge-std/Test.sol";
import { AttestationStation } from "../universal/op-nft/AttestationStation.sol";
import { OptimistInviter } from "../universal/op-nft/OptimistInviter.sol";
import { Optimist } from "../universal/op-nft/Optimist.sol";
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol";
import { TestERC1271Wallet } from "../testing/helpers/TestERC1271Wallet.sol";
import { OptimistInviterHelper } from "../testing/helpers/OptimistInviterHelper.sol";
import { OptimistConstants } from "../universal/op-nft/libraries/OptimistConstants.sol";
contract OptimistInviter_Initializer is Test {
event InviteClaimed(address indexed issuer, address indexed claimer);
event Initialized(uint8 version);
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event AttestationCreated(
address indexed creator,
address indexed about,
bytes32 indexed key,
bytes val
);
bytes32 EIP712_DOMAIN_TYPEHASH;
address internal alice_inviteGranter;
address internal sally;
address internal ted;
address internal eve;
address internal bob;
uint256 internal bobPrivateKey;
address internal carol;
uint256 internal carolPrivateKey;
TestERC1271Wallet carolERC1271Wallet;
AttestationStation attestationStation;
OptimistInviter optimistInviter;
OptimistInviterHelper optimistInviterHelper;
function setUp() public {
alice_inviteGranter = makeAddr("alice_inviteGranter");
sally = makeAddr("sally");
ted = makeAddr("ted");
eve = makeAddr("eve");
bobPrivateKey = 0xB0B0B0B0;
bob = vm.addr(bobPrivateKey);
carolPrivateKey = 0xC0C0C0C0;
carol = vm.addr(carolPrivateKey);
carolERC1271Wallet = new TestERC1271Wallet(carol);
// Give alice and bob and sally some ETH
vm.deal(alice_inviteGranter, 1 ether);
vm.deal(bob, 1 ether);
vm.deal(sally, 1 ether);
vm.deal(ted, 1 ether);
vm.deal(eve, 1 ether);
EIP712_DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
_initializeContracts();
}
/**
* @notice Instantiates an AttestationStation, and an OptimistInviter.
*/
function _initializeContracts() internal {
attestationStation = new AttestationStation();
optimistInviter = new OptimistInviter(alice_inviteGranter, attestationStation);
vm.expectEmit(true, true, true, true, address(optimistInviter));
emit Initialized(1);
optimistInviter.initialize("OptimistInviter");
optimistInviterHelper = new OptimistInviterHelper(optimistInviter, "OptimistInviter");
}
function _passMinCommitmentPeriod() internal {
vm.warp(optimistInviter.MIN_COMMITMENT_PERIOD() + block.timestamp);
}
/**
* @notice Returns a user's current invite count, as stored in the AttestationStation.
*/
function _getInviteCount(address _issuer) internal view returns (uint256) {
return optimistInviter.inviteCounts(_issuer);
}
/**
* @notice Returns true if claimer has the proper attestation from OptimistInviter to mint.
*/
function _hasMintAttestation(address _claimer) internal view returns (bool) {
bytes memory attestation = attestationStation.attestations(
address(optimistInviter),
_claimer,
OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY
);
return attestation.length > 0;
}
/**
* @notice Get signature as a bytes blob, since SignatureChecker takes arbitrary signature blobs.
*
*/
function _getSignature(uint256 _signingPrivateKey, bytes32 _digest)
internal
pure
returns (bytes memory)
{
(uint8 v, bytes32 r, bytes32 s) = vm.sign(_signingPrivateKey, _digest);
bytes memory signature = abi.encodePacked(r, s, v);
return signature;
}
/**
* @notice Signs a claimable invite with the given private key and returns the signature using
* correct EIP712 domain separator.
*/
function _issueInviteAs(uint256 _privateKey)
internal
returns (OptimistInviter.ClaimableInvite memory, bytes memory)
{
return
_issueInviteWithEIP712Domain(
_privateKey,
bytes("OptimistInviter"),
bytes(optimistInviter.EIP712_VERSION()),
block.chainid,
address(optimistInviter)
);
}
/**
* @notice Signs a claimable invite with the given private key and returns the signature using
* the given EIP712 domain separator. This assumes that the issuer's address is the
* corresponding public key to _issuerPrivateKey.
*/
function _issueInviteWithEIP712Domain(
uint256 _issuerPrivateKey,
bytes memory _eip712Name,
bytes memory _eip712Version,
uint256 _eip712Chainid,
address _eip712VerifyingContract
) internal returns (OptimistInviter.ClaimableInvite memory, bytes memory) {
address issuer = vm.addr(_issuerPrivateKey);
OptimistInviter.ClaimableInvite memory claimableInvite = optimistInviterHelper
.getClaimableInviteWithNewNonce(issuer);
return (
claimableInvite,
_getSignature(
_issuerPrivateKey,
optimistInviterHelper.getDigestWithEIP712Domain(
claimableInvite,
_eip712Name,
_eip712Version,
_eip712Chainid,
_eip712VerifyingContract
)
)
);
}
/**
* @notice Commits a signature and claimer address to the OptimistInviter contract.
*/
function _commitInviteAs(address _as, bytes memory _signature) internal {
vm.prank(_as);
bytes32 hashedSignature = keccak256(abi.encode(_as, _signature));
optimistInviter.commitInvite(hashedSignature);
// Check that the commitment was stored correctly
assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp);
}
/**
* @notice Signs a claimable invite with the given private key. The claimer commits then claims
* the invite. Checks that all expected events are emitted and that state is updated
* correctly. Returns the signature and invite for use in tests.
*/
function _issueThenClaimShouldSucceed(uint256 _issuerPrivateKey, address _claimer)
internal
returns (OptimistInviter.ClaimableInvite memory, bytes memory)
{
address issuer = vm.addr(_issuerPrivateKey);
uint256 prevInviteCount = _getInviteCount(issuer);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteAs(_issuerPrivateKey);
_commitInviteAs(_claimer, signature);
// The hash(claimer ++ signature) should be committed
assertEq(
optimistInviter.commitmentTimestamps(keccak256(abi.encode(_claimer, signature))),
block.timestamp
);
_passMinCommitmentPeriod();
// OptimistInviter should issue a new attestation allowing claimer to mint
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
_claimer,
OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY,
abi.encode(issuer)
);
// Should emit an event indicating that the invite was claimed
vm.expectEmit(true, false, false, false, address(optimistInviter));
emit InviteClaimed(issuer, _claimer);
vm.prank(_claimer);
optimistInviter.claimInvite(_claimer, claimableInvite, signature);
// The nonce that issuer used should be marked as used
assertTrue(optimistInviter.usedNonces(issuer, claimableInvite.nonce));
// Issuer should have one less invite
assertEq(prevInviteCount - 1, _getInviteCount(issuer));
// Claimer should have the mint attestation from the OptimistInviter contract
assertTrue(_hasMintAttestation(_claimer));
return (claimableInvite, signature);
}
/**
* @notice Issues 3 invites to the given address. Checks that all expected events are emitted
* and that state is updated correctly.
*/
function _grantInvitesTo(address _to) internal {
address[] memory addresses = new address[](1);
addresses[0] = _to;
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
_to,
optimistInviter.CAN_INVITE_ATTESTATION_KEY(),
bytes("true")
);
vm.prank(alice_inviteGranter);
optimistInviter.setInviteCounts(addresses, 3);
assertEq(_getInviteCount(_to), 3);
}
}
contract OptimistInviterTest is OptimistInviter_Initializer {
function test_initialize() external {
// expect attestationStation to be set
assertEq(address(optimistInviter.ATTESTATION_STATION()), address(attestationStation));
assertEq(optimistInviter.INVITE_GRANTER(), alice_inviteGranter);
assertEq(optimistInviter.version(), "1.0.0");
}
/**
* @notice Alice the admin should be able to give Bob, Sally, and Carol 3 invites, and the
* OptimistInviter contract should increment invite counts on inviteCounts and issue
* 'optimist.can-invite' attestations.
*/
function test_grantInvites_adminAddingInvites_succeeds() external {
address[] memory addresses = new address[](3);
addresses[0] = bob;
addresses[1] = sally;
addresses[2] = address(carolERC1271Wallet);
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
bob,
optimistInviter.CAN_INVITE_ATTESTATION_KEY(),
bytes("true")
);
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
sally,
optimistInviter.CAN_INVITE_ATTESTATION_KEY(),
bytes("true")
);
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
address(carolERC1271Wallet),
optimistInviter.CAN_INVITE_ATTESTATION_KEY(),
bytes("true")
);
vm.prank(alice_inviteGranter);
optimistInviter.setInviteCounts(addresses, 3);
assertEq(_getInviteCount(bob), 3);
assertEq(_getInviteCount(sally), 3);
assertEq(_getInviteCount(address(carolERC1271Wallet)), 3);
}
/**
* @notice Bob, who is not the invite granter, should not be able to issue invites.
*/
function test_grantInvites_nonAdminAddingInvites_reverts() external {
address[] memory addresses = new address[](2);
addresses[0] = bob;
addresses[1] = sally;
vm.expectRevert("OptimistInviter: only invite granter can grant invites");
vm.prank(bob);
optimistInviter.setInviteCounts(addresses, 3);
}
/**
* @notice Sally should be able to commit an invite given by by Bob.
*/
function test_commitInvite_committingForYourself_succeeds() external {
_grantInvitesTo(bob);
(, bytes memory signature) = _issueInviteAs(bobPrivateKey);
vm.prank(sally);
bytes32 hashedSignature = keccak256(abi.encode(sally, signature));
optimistInviter.commitInvite(hashedSignature);
assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp);
}
/**
* @notice Sally should be able to Bob's for a different claimer, Eve.
*/
function test_commitInvite_committingForSomeoneElse_succeeds() external {
_grantInvitesTo(bob);
(, bytes memory signature) = _issueInviteAs(bobPrivateKey);
vm.prank(sally);
bytes32 hashedSignature = keccak256(abi.encode(eve, signature));
optimistInviter.commitInvite(hashedSignature);
assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp);
}
/**
* @notice Attempting to commit the same hash twice should revert. This prevents griefing.
*/
function test_commitInvite_committingSameHashTwice_reverts() external {
_grantInvitesTo(bob);
(, bytes memory signature) = _issueInviteAs(bobPrivateKey);
vm.prank(sally);
bytes32 hashedSignature = keccak256(abi.encode(eve, signature));
optimistInviter.commitInvite(hashedSignature);
assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp);
vm.expectRevert("OptimistInviter: commitment already made");
optimistInviter.commitInvite(hashedSignature);
}
/**
* @notice Bob issues signature, and Sally claims the invite. Bob's invite count should be
* decremented, and Sally should be able to mint.
*/
function test_claimInvite_succeeds() external {
_grantInvitesTo(bob);
_issueThenClaimShouldSucceed(bobPrivateKey, sally);
}
/**
* @notice Bob issues signature, and Ted commits the invite for Sally. Eve claims for Sally.
*/
function test_claimInvite_claimForSomeoneElse_succeeds() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteAs(bobPrivateKey);
vm.prank(ted);
optimistInviter.commitInvite(keccak256(abi.encode(sally, signature)));
_passMinCommitmentPeriod();
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
sally,
OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY,
abi.encode(bob)
);
// Should emit an event indicating that the invite was claimed
vm.expectEmit(true, true, true, true, address(optimistInviter));
emit InviteClaimed(bob, sally);
vm.prank(eve);
optimistInviter.claimInvite(sally, claimableInvite, signature);
assertEq(_getInviteCount(bob), 2);
assertTrue(_hasMintAttestation(sally));
assertFalse(_hasMintAttestation(eve));
}
function test_claimInvite_claimBeforeMinCommitmentPeriod_reverts() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteAs(bobPrivateKey);
_commitInviteAs(sally, signature);
// Some time passes, but not enough to meet the minimum commitment period
vm.warp(block.timestamp + 10);
vm.expectRevert("OptimistInviter: minimum commitment period has not elapsed yet");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
}
/**
* @notice Signature issued for previous versions of the contract should fail.
*/
function test_claimInvite_usingSignatureIssuedForDifferentVersion_reverts() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteWithEIP712Domain(
bobPrivateKey,
"OptimismInviter",
"0.9.1",
block.chainid,
address(optimistInviter)
);
_commitInviteAs(sally, signature);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: invalid signature");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
}
/**
* @notice Replay attack for signature issued for contract on different chain (ie. mainnet)
* should fail.
*/
function test_claimInvite_usingSignatureIssuedForDifferentChain_reverts() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteWithEIP712Domain(
bobPrivateKey,
"OptimismInviter",
bytes(optimistInviter.EIP712_VERSION()),
1,
address(optimistInviter)
);
_commitInviteAs(sally, signature);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: invalid signature");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
}
/**
* @notice Replay attack for signature issued for instantiation of the OptimistInviter contract
* on a different address should fail.
*/
function test_claimInvite_usingSignatureIssuedForDifferentContract_reverts() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteWithEIP712Domain(
bobPrivateKey,
"OptimismInviter",
bytes(optimistInviter.EIP712_VERSION()),
block.chainid,
address(0xBEEF)
);
_commitInviteAs(sally, signature);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: invalid signature");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
}
/**
* @notice Attempting to claim again using the same signature again should fail.
*/
function test_claimInvite_replayingUsedNonce_reverts() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueThenClaimShouldSucceed(bobPrivateKey, sally);
// Sally tries to claim the invite using the same signature
vm.expectRevert("OptimistInviter: nonce has already been used");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
// Carol tries to claim the invite using the same signature
_commitInviteAs(carol, signature);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: nonce has already been used");
vm.prank(carol);
optimistInviter.claimInvite(carol, claimableInvite, signature);
}
/**
* @notice Issuing signatures through a contract that implements ERC1271 should succeed (ie.
* Gnosis Safe or other smart contract wallets). Carol is using a ERC1271 contract
* wallet that is simply backed by her private key.
*/
function test_claimInvite_usingERC1271Wallet_succeeds() external {
_grantInvitesTo(address(carolERC1271Wallet));
OptimistInviter.ClaimableInvite memory claimableInvite = optimistInviterHelper
.getClaimableInviteWithNewNonce(address(carolERC1271Wallet));
bytes memory signature = _getSignature(
carolPrivateKey,
optimistInviterHelper.getDigest(claimableInvite)
);
// Sally tries to claim the invite
_commitInviteAs(sally, signature);
_passMinCommitmentPeriod();
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
address(optimistInviter),
sally,
OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY,
abi.encode(address(carolERC1271Wallet))
);
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
assertEq(_getInviteCount(address(carolERC1271Wallet)), 2);
}
/**
* @notice Claimer must commit the signature before claiming the invite. Sally attempts to
* claim the Bob's invite without committing the signature first.
*/
function test_claimInvite_withoutCommittingHash_reverts() external {
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteAs(bobPrivateKey);
vm.expectRevert("OptimistInviter: claimer and signature have not been committed yet");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
}
/**
* @notice Using a signature that doesn't correspond to the claimable invite should fail.
*/
function test_claimInvite_withIncorrectSignature_reverts() external {
_grantInvitesTo(carol);
_grantInvitesTo(bob);
(
OptimistInviter.ClaimableInvite memory bobClaimableInvite,
bytes memory bobSignature
) = _issueInviteAs(bobPrivateKey);
(, bytes memory carolSignature) = _issueInviteAs(carolPrivateKey);
_commitInviteAs(sally, bobSignature);
_commitInviteAs(sally, carolSignature);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: invalid signature");
vm.prank(sally);
optimistInviter.claimInvite(sally, bobClaimableInvite, carolSignature);
}
/**
* @notice Attempting to use a signature from a issuer who never was granted invites should
* fail.
*/
function test_claimInvite_whenIssuerNeverReceivedInvites_reverts() external {
// Bob was never granted any invites, but issues an invite for Eve
(
OptimistInviter.ClaimableInvite memory claimableInvite,
bytes memory signature
) = _issueInviteAs(bobPrivateKey);
_commitInviteAs(sally, signature);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: issuer has no invites");
vm.prank(sally);
optimistInviter.claimInvite(sally, claimableInvite, signature);
}
/**
* @notice Attempting to use a signature from a issuer who has no more invites should fail.
* Bob has 3 invites, but issues 4 invites for Sally, Carol, Ted, and Eve. Only the
* first 3 invites should be claimable. The last claimer, Eve, should not be able to
* claim the invite.
*
*/
function test_claimInvite_whenIssuerHasNoInvitesLeft_reverts() external {
_grantInvitesTo(bob);
_issueThenClaimShouldSucceed(bobPrivateKey, sally);
_issueThenClaimShouldSucceed(bobPrivateKey, carol);
_issueThenClaimShouldSucceed(bobPrivateKey, ted);
assertEq(_getInviteCount(bob), 0);
(
OptimistInviter.ClaimableInvite memory claimableInvite4,
bytes memory signature4
) = _issueInviteAs(bobPrivateKey);
_commitInviteAs(eve, signature4);
_passMinCommitmentPeriod();
vm.expectRevert("OptimistInviter: issuer has no invites");
vm.prank(eve);
optimistInviter.claimInvite(eve, claimableInvite4, signature4);
assertEq(_getInviteCount(bob), 0);
}
}
//SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import { OptimistInviter } from "../../universal/op-nft/OptimistInviter.sol";
/**
* Simple helper contract that helps with testing flow and signature for OptimistInviter contract.
* Made this a separate contract instead of including in OptimistInviter.t.sol for reusability.
*/
contract OptimistInviterHelper {
/**
* @notice EIP712 typehash for the ClaimableInvite type.
*/
bytes32 public constant CLAIMABLE_INVITE_TYPEHASH =
keccak256("ClaimableInvite(address issuer,bytes32 nonce)");
/**
* @notice EIP712 typehash for the EIP712Domain type that is included as part of the signature.
*/
bytes32 public constant EIP712_DOMAIN_TYPEHASH =
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
/**
* @notice Address of OptimistInviter contract we are testing.
*/
OptimistInviter public optimistInviter;
/**
* @notice OptimistInviter contract name. Used to construct the EIP-712 domain.
*/
string public name;
/**
* @notice Keeps track of current nonce to generate new nonces for each invite.
*/
uint256 public currentNonce;
constructor(OptimistInviter _optimistInviter, string memory _name) {
optimistInviter = _optimistInviter;
name = _name;
}
/**
* @notice Returns the hash of the struct ClaimableInvite.
*
* @param _claimableInvite ClaimableInvite struct to hash.
*
* @return EIP-712 typed struct hash.
*/
function getClaimableInviteStructHash(OptimistInviter.ClaimableInvite memory _claimableInvite)
public
pure
returns (bytes32)
{
return
keccak256(
abi.encode(
CLAIMABLE_INVITE_TYPEHASH,
_claimableInvite.issuer,
_claimableInvite.nonce
)
);
}
/**
* @notice Returns a bytes32 nonce that should change everytime. In practice, people should use
* pseudorandom nonces.
*
* @return Nonce that should be used as part of ClaimableInvite.
*/
function consumeNonce() public returns (bytes32) {
return bytes32(keccak256(abi.encode(currentNonce++)));
}
/**
* @notice Returns a ClaimableInvite with the issuer and current nonce.
*
* @param _issuer Issuer to include in the ClaimableInvite.
*
* @return ClaimableInvite that can be hashed & signed.
*/
function getClaimableInviteWithNewNonce(address _issuer)
public
returns (OptimistInviter.ClaimableInvite memory)
{
return OptimistInviter.ClaimableInvite(_issuer, consumeNonce());
}
/**
* @notice Computes the EIP712 digest with default correct parameters.
*
* @param _claimableInvite ClaimableInvite struct to hash.
*
* @return EIP-712 compatible digest.
*/
function getDigest(OptimistInviter.ClaimableInvite calldata _claimableInvite)
public
view
returns (bytes32)
{
return
getDigestWithEIP712Domain(
_claimableInvite,
bytes(name),
bytes(optimistInviter.EIP712_VERSION()),
block.chainid,
address(optimistInviter)
);
}
/**
* @notice Computes the EIP712 digest with the given domain parameters.
* Used for testing that different domain parameters fail.
*
* @param _claimableInvite ClaimableInvite struct to hash.
* @param _name Contract name to use in the EIP712 domain.
* @param _version Contract version to use in the EIP712 domain.
* @param _chainid Chain ID to use in the EIP712 domain.
* @param _verifyingContract Address to use in the EIP712 domain.
*
* @return EIP-712 compatible digest.
*/
function getDigestWithEIP712Domain(
OptimistInviter.ClaimableInvite calldata _claimableInvite,
bytes memory _name,
bytes memory _version,
uint256 _chainid,
address _verifyingContract
) public pure returns (bytes32) {
bytes32 domainSeparator = keccak256(
abi.encode(
EIP712_DOMAIN_TYPEHASH,
keccak256(_name),
keccak256(_version),
_chainid,
_verifyingContract
)
);
return
ECDSA.toTypedDataHash(domainSeparator, getClaimableInviteStructHash(_claimableInvite));
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// solhint-disable max-line-length
/**
* Simple ERC1271 wallet that can be used to test the ERC1271 signature checker.
* https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/mocks/ERC1271WalletMock.sol
*/
contract TestERC1271Wallet is Ownable, IERC1271 {
constructor(address originalOwner) {
transferOwnership(originalOwner);
}
function isValidSignature(bytes32 hash, bytes memory signature)
public
view
override
returns (bytes4 magicValue)
{
return
ECDSA.recover(hash, signature) == owner() ? this.isValidSignature.selector : bytes4(0);
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { OptimistConstants } from "./libraries/OptimistConstants.sol";
import { Semver } from "@eth-optimism/contracts-bedrock/contracts/universal/Semver.sol";
import { AttestationStation } from "./AttestationStation.sol";
import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import {
EIP712Upgradeable
} from "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol";
/**
* @custom:upgradeable
* @title OptimistInviter
* @notice OptimistInviter issues "optimist.can-invite" and "optimist.can-mint-from-invite"
* attestations. Accounts that have invites can issue signatures that allow other
* accounts to claim an invite. The invitee uses a claim and reveal flow to claim the
* invite to an address of their choosing.
*
* Parties involved:
* 1) INVITE_GRANTER: trusted account that can allow accounts to issue invites
* 2) issuer: account that is allowed to issue invites
* 3) claimer: account that receives the invites
*
* Flow:
* 1) INVITE_GRANTER calls _setInviteCount to allow an issuer to issue a certain number
* of invites, and also creates a "optimist.can-invite" attestation for the issuer
* 2) Off-chain, the issuer signs (EIP-712) a ClaimableInvite to produce a signature
* 3) Off-chain, invite issuer sends the plaintext ClaimableInvite and the signature
* to the recipient
* 4) claimer chooses an address they want to receive the invite on
* 5) claimer commits the hash of the address they want to receive the invite on and the
* received signature keccak256(abi.encode(addressToReceiveTo, receivedSignature))
* using the commitInvite function
* 6) claimer waits for the MIN_COMMITMENT_PERIOD to pass.
* 7) claimer reveals the plaintext ClaimableInvite and the signature using the
* claimInvite function, receiving the "optimist.can-mint-from-invite" attestation
*/
contract OptimistInviter is Semver, EIP712Upgradeable {
/**
* @notice Emitted when an invite is claimed.
*
* @param issuer Address that issued the signature.
* @param claimer Address that claimed the invite.
*/
event InviteClaimed(address indexed issuer, address indexed claimer);
/**
* @notice Version used for the EIP712 domain separator. This version is separated from the
* contract semver because the EIP712 domain separator is used to sign messages, and
* changing the domain separator invalidates all existing signatures. We should only
* bump this version if we make a major change to the signature scheme.
*/
string public constant EIP712_VERSION = "1.0.0";
/**
* @notice EIP712 typehash for the ClaimableInvite type.
*/
bytes32 public constant CLAIMABLE_INVITE_TYPEHASH =
keccak256("ClaimableInvite(address issuer,bytes32 nonce)");
/**
* @notice Attestation key for that signals that an account was allowed to issue invites
*/
bytes32 public constant CAN_INVITE_ATTESTATION_KEY = bytes32("optimist.can-invite");
/**
* @notice Granter who can set accounts' invite counts.
*/
address public immutable INVITE_GRANTER;
/**
* @notice Address of the AttestationStation contract.
*/
AttestationStation public immutable ATTESTATION_STATION;
/**
* @notice Minimum age of a commitment (in seconds) before it can be revealed using claimInvite.
* Currently set to 60 seconds.
*
* Prevents an attacker from front-running a commitment by taking the signature in the
* claimInvite call and quickly committing and claiming it before the the claimer's
* transaction succeeds. With this, frontrunning a commitment requires that an attacker
* be able to prevent the honest claimer's claimInvite transaction from being included
* for this long.
*/
uint256 public constant MIN_COMMITMENT_PERIOD = 60;
/**
* @notice Struct that represents a claimable invite that will be signed by the issuer.
*
* @custom:field issuer Address that issued the signature. Reason this is explicitly included,
* and not implicitly assumed to be the recovered address from the
* signature is that the issuer may be using a ERC-1271 compatible
* contract wallet, where the recovered address is not the same as the
* issuer, or the signature is not an ECDSA signature at all.
* @custom:field nonce Pseudorandom nonce to prevent replay attacks.
*/
struct ClaimableInvite {
address issuer;
bytes32 nonce;
}
/**
* @notice Maps from hashes to the timestamp when they were committed.
*/
mapping(bytes32 => uint256) public commitmentTimestamps;
/**
* @notice Maps from addresses to nonces to whether or not they have been used.
*/
mapping(address => mapping(bytes32 => bool)) public usedNonces;
/**
* @notice Maps from addresses to number of invites they have.
*/
mapping(address => uint256) public inviteCounts;
/**
* @custom:semver 1.0.0
*
* @param _inviteGranter Address of the invite granter.
* @param _attestationStation Address of the AttestationStation contract.
*/
constructor(address _inviteGranter, AttestationStation _attestationStation) Semver(1, 0, 0) {
INVITE_GRANTER = _inviteGranter;
ATTESTATION_STATION = _attestationStation;
}
/**
* @notice Initializes this contract, setting the EIP712 context.
*
* Only update the EIP712_VERSION when there is a change to the signature scheme.
* After the EIP712 version is changed, any signatures issued off-chain but not
* claimed yet will no longer be accepted by the claimInvite function. Please make
* sure to notify the issuers that they must re-issue their invite signatures.
*
* @param _name Contract name.
*/
function initialize(string memory _name) public initializer {
__EIP712_init(_name, EIP712_VERSION);
}
/**
* @notice Allows invite granter to set the number of invites an address has.
*
* @param _accounts An array of accounts to update the invite counts of.
* @param _inviteCount Number of invites to set to.
*/
function setInviteCounts(address[] calldata _accounts, uint256 _inviteCount) public {
// Only invite granter can grant invites
require(
msg.sender == INVITE_GRANTER,
"OptimistInviter: only invite granter can grant invites"
);
uint256 length = _accounts.length;
AttestationStation.AttestationData[]
memory attestations = new AttestationStation.AttestationData[](length);
for (uint256 i; i < length; ) {
// Set invite count for account to _inviteCount
inviteCounts[_accounts[i]] = _inviteCount;
// Create an attestation for posterity that the account is allowed to create invites
attestations[i] = AttestationStation.AttestationData({
about: _accounts[i],
key: CAN_INVITE_ATTESTATION_KEY,
val: bytes("true")
});
unchecked {
++i;
}
}
ATTESTATION_STATION.attest(attestations);
}
/**
* @notice Allows anyone (but likely the claimer) to commit a received signature along with the
* address to claim to.
*
* Before calling this function, the claimer should have received a signature from the
* issuer off-chain. The claimer then calls this function with the hash of the
* claimer's address and the received signature. This is necessary to prevent
* front-running when the invitee is claiming the invite. Without a commit and reveal
* scheme, anyone who is watching the mempool can take the signature being submitted
* and front run the transaction to claim the invite to their own address.
*
* The same commitment can only be made once, and the function reverts if the
* commitment has already been made. This prevents griefing where a malicious party can
* prevent the original claimer from being able to claimInvite.
*
*
* @param _commitment A hash of the claimer and signature concatenated.
* keccak256(abi.encode(_claimer, _signature))
*/
function commitInvite(bytes32 _commitment) public {
// Check that the commitment hasn't already been made. This prevents griefing where
// a malicious party continuously re-submits the same commitment, preventing the original
// claimer from claiming their invite by resetting the minimum commitment period.
require(commitmentTimestamps[_commitment] == 0, "OptimistInviter: commitment already made");
commitmentTimestamps[_commitment] = block.timestamp;
}
/**
* @notice Allows anyone to reveal a commitment and claim an invite.
*
* The hash, keccak256(abi.encode(_claimer, _signature)), should have been already
* committed using commitInvite. Before issuing the "optimist.can-mint-from-invite"
* attestation, this function checks that
* 1) the hash corresponding to the _claimer and the _signature was committed
* 2) MIN_COMMITMENT_PERIOD has passed since the commitment was made.
* 3) the _signature is signed correctly by the issuer
* 4) the _signature hasn't already been used to claim an invite before
* 5) the _signature issuer has not used up all of their invites
* This function doesn't require that the _claimer is calling this function.
*
* @param _claimer Address that will be granted the invite.
* @param _claimableInvite ClaimableInvite struct containing the issuer and nonce.
* @param _signature Signature signed over the claimable invite.
*/
function claimInvite(
address _claimer,
ClaimableInvite calldata _claimableInvite,
bytes memory _signature
) public {
uint256 commitmentTimestamp = commitmentTimestamps[
keccak256(abi.encode(_claimer, _signature))
];
// Make sure the claimer and signature have been committed.
require(
commitmentTimestamp > 0,
"OptimistInviter: claimer and signature have not been committed yet"
);
// Check that MIN_COMMITMENT_PERIOD has passed since the commitment was made.
require(
commitmentTimestamp + MIN_COMMITMENT_PERIOD <= block.timestamp,
"OptimistInviter: minimum commitment period has not elapsed yet"
);
// Generate a EIP712 typed data hash to compare against the signature.
bytes32 digest = _hashTypedDataV4(
keccak256(
abi.encode(
CLAIMABLE_INVITE_TYPEHASH,
_claimableInvite.issuer,
_claimableInvite.nonce
)
)
);
// Uses SignatureChecker, which supports both regular ECDSA signatures from EOAs as well as
// ERC-1271 signatures from contract wallets or multi-sigs. This means that if the issuer
// wants to revoke a signature, they can use a smart contract wallet to issue the signature,
// then invalidate the signature after issuing it.
require(
SignatureChecker.isValidSignatureNow(_claimableInvite.issuer, digest, _signature),
"OptimistInviter: invalid signature"
);
// The issuer's signature commits to a nonce to prevent replay attacks.
// This checks that the nonce has not been used for this issuer before. The nonces are
// scoped to the issuer address, so the same nonce can be used by different issuers without
// clashing.
require(
usedNonces[_claimableInvite.issuer][_claimableInvite.nonce] == false,
"OptimistInviter: nonce has already been used"
);
// Set the nonce as used for the issuer so that it cannot be replayed.
usedNonces[_claimableInvite.issuer][_claimableInvite.nonce] = true;
// Failing this check means that the issuer has used up all of their existing invites.
require(
inviteCounts[_claimableInvite.issuer] > 0,
"OptimistInviter: issuer has no invites"
);
// Reduce the issuer's invite count by 1. Can be unchecked because we check above that
// count is > 0.
unchecked {
--inviteCounts[_claimableInvite.issuer];
}
// Create the attestation that the claimer can mint from the issuer's invite.
// The invite issuer is included in the data of the attestation.
ATTESTATION_STATION.attest(
_claimer,
OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY,
abi.encode(_claimableInvite.issuer)
);
emit InviteClaimed(_claimableInvite.issuer, _claimer);
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
/**
* @title OptimistConstants
* @notice Library for storing Optimist related constants that are shared in multiple contracts.
*/
library OptimistConstants {
/**
* @notice Attestation key issued by OptimistInviter allowing the attested account to mint.
*/
bytes32 internal constant OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY =
bytes32("optimist.can-mint-from-invite");
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment