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

accounts, signer: implement gnosis safe support #21593

Merged
merged 7 commits into from
Sep 29, 2020
Merged
58 changes: 58 additions & 0 deletions cmd/clef/extapi_changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,64 @@ TL;DR: Given a version number MAJOR.MINOR.PATCH, increment the:

Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.

### 6.1.0

The API-method `account_signGnosisTx` was added. This method takes two parameters,
holiman marked this conversation as resolved.
Show resolved Hide resolved
`[address, safeTx]`. The latter, `safeTx`, can be copy-pasted from the gnosis relay. For example:

```
{
"jsonrpc": "2.0",
"method": "account_signGnosisTx",
"params": ["0xfd1c4226bfD1c436672092F4eCbfC270145b7256",
{
"safe": "0x25a6c4BBd32B2424A9c99aEB0584Ad12045382B3",
"to": "0xB372a646f7F05Cc1785018dBDA7EBc734a2A20E2",
"value": "20000000000000000",
"data": null,
"operation": 0,
"gasToken": "0x0000000000000000000000000000000000000000",
"safeTxGas": 27845,
"baseGas": 0,
"gasPrice": "0",
"refundReceiver": "0x0000000000000000000000000000000000000000",
"nonce": 2,
"executionDate": null,
"submissionDate": "2020-09-15T21:54:49.617634Z",
"modified": "2020-09-15T21:54:49.617634Z",
"blockNumber": null,
"transactionHash": null,
"safeTxHash": "0x2edfbd5bc113ff18c0631595db32eb17182872d88d9bf8ee4d8c2dd5db6d95e2",
"executor": null,
"isExecuted": false,
"isSuccessful": null,
"ethGasPrice": null,
"gasUsed": null,
"fee": null,
"origin": null,
"dataDecoded": null,
"confirmationsRequired": null,
"confirmations": [
{
"owner": "0xAd2e180019FCa9e55CADe76E4487F126Fd08DA34",
"submissionDate": "2020-09-15T21:54:49.663299Z",
"transactionHash": null,
"confirmationType": "CONFIRMATION",
"signature": "0x95a7250bb645f831c86defc847350e7faff815b2fb586282568e96cc859e39315876db20a2eed5f7a0412906ec5ab57652a6f645ad4833f345bda059b9da2b821c",
"signatureType": "EOA"
}
],
"signatures": null
}
],
"id": 67
}
```

Not all fields are required, though. This method is really just a UX helper, which massages the
input to conform to the `EIP-712` [specification](https://docs.gnosis.io/safe/docs/contracts_tx_execution/#transaction-hash)
for the Gnosis Safe, and making the output be directly import:able to by a relay service.
holiman marked this conversation as resolved.
Show resolved Hide resolved


### 6.0.0

Expand Down
34 changes: 34 additions & 0 deletions common/math/big.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ func (i *HexOrDecimal256) MarshalText() ([]byte, error) {
return []byte(fmt.Sprintf("%#x", (*big.Int)(i))), nil
}

// Decimal256 unmarshals big.Int as a decimal string. When unmarshalling,
// it however accepts either "0x"-prefixed (hex encoded) or non-prefixed (decimal)
type Decimal256 big.Int

// NewHexOrDecimal256 creates a new Decimal256
func NewDecimal256(x int64) *Decimal256 {
b := big.NewInt(x)
d := Decimal256(*b)
return &d
}

// UnmarshalText implements encoding.TextUnmarshaler.
func (i *Decimal256) UnmarshalText(input []byte) error {
bigint, ok := ParseBig256(string(input))
if !ok {
return fmt.Errorf("invalid hex or decimal integer %q", input)
}
*i = Decimal256(*bigint)
return nil
}

// MarshalText implements encoding.TextMarshaler.
func (i *Decimal256) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}

// String implements Stringer.
func (i *Decimal256) String() string {
if i == nil {
return "0"
}
return fmt.Sprintf("%#d", (*big.Int)(i))
}

// ParseBig256 parses s as a 256 bit integer in decimal or hexadecimal syntax.
// Leading zeros are accepted. The empty string parses as zero.
func ParseBig256(s string) (*big.Int, bool) {
Expand Down
32 changes: 31 additions & 1 deletion signer/core/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const (
// numberOfAccountsToDerive For hardware wallets, the number of accounts to derive
numberOfAccountsToDerive = 10
// ExternalAPIVersion -- see extapi_changelog.md
ExternalAPIVersion = "6.0.0"
ExternalAPIVersion = "6.1.0"
// InternalAPIVersion -- see intapi_changelog.md
InternalAPIVersion = "7.0.1"
)
Expand All @@ -62,6 +62,8 @@ type ExternalAPI interface {
EcRecover(ctx context.Context, data hexutil.Bytes, sig hexutil.Bytes) (common.Address, error)
// Version info about the APIs
Version(ctx context.Context) (string, error)
// SignGnosisSafeTransaction signs/confirms a gnosis-safe multisig transaction
SignGnosisTx(ctx context.Context, signerAddress common.MixedcaseAddress, gnosisTx GnosisSafeTx, methodSelector *string) (*GnosisSafeTx, error)
}

// UIClientAPI specifies what method a UI needs to implement to be able to be used as a
Expand Down Expand Up @@ -234,6 +236,7 @@ type (
Address common.MixedcaseAddress `json:"address"`
Rawdata []byte `json:"raw_data"`
Messages []*NameValueType `json:"messages"`
Callinfo []ValidationInfo `json:"call_info"`
Copy link
Member

Choose a reason for hiding this comment

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

this doesn't seem to be used anywhere

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right... I added that there, then converted all validation-messags into NVT messages instead, having forgotten about that^ . I'll use that instead

Copy link
Member

Choose a reason for hiding this comment

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

this doesn't seem to be used anywhere

Hash hexutil.Bytes `json:"hash"`
Meta Metadata `json:"meta"`
}
Expand Down Expand Up @@ -581,6 +584,33 @@ func (api *SignerAPI) SignTransaction(ctx context.Context, args SendTxArgs, meth

}

func (api *SignerAPI) SignGnosisTx(ctx context.Context, signerAddress common.MixedcaseAddress, gnosisTx GnosisSafeTx, methodSelector *string) (*GnosisSafeTx, error) {
// Do the usual validations, but on the last-stage transaction
args := gnosisTx.ArgsForValidation()
msgs, err := api.validator.ValidateTransaction(methodSelector, args)
if err != nil {
return nil, err
}
holiman marked this conversation as resolved.
Show resolved Hide resolved
// If we are in 'rejectMode', then reject rather than show the user warnings
if api.rejectMode {
if err := msgs.getWarnings(); err != nil {
return nil, err
}
}
typedData := gnosisTx.ToTypedData()
signature, preimage, err := api.signTypedData(ctx, signerAddress, typedData)
if err != nil {
return nil, err
}
checkSummedSender, _ := common.NewMixedcaseAddressFromString(signerAddress.Address().Hex())

gnosisTx.Signature = signature
gnosisTx.SafeTxHash = common.BytesToHash(preimage)
gnosisTx.Sender = *checkSummedSender // Must be checksumed to be accepted by relay

return &gnosisTx, nil
}

// Returns the external api version. This method does not require user acceptance. Available methods are
// available via enumeration anyway, and this info does not contain user-specific data
func (api *SignerAPI) Version(ctx context.Context) (string, error) {
Expand Down
16 changes: 16 additions & 0 deletions signer/core/auditlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ func (l *AuditLogger) SignData(ctx context.Context, contentType string, addr com
return b, e
}

func (l *AuditLogger) SignGnosisTx(ctx context.Context, addr common.MixedcaseAddress, gnosisTx GnosisSafeTx, methodSelector *string) (*GnosisSafeTx, error) {
sel := "<nil>"
if methodSelector != nil {
sel = *methodSelector
}
l.log.Info("SignGnosisTx", "type", "request", "metadata", MetadataFromContext(ctx).String(),
"addr", addr.String(), "data", gnosisTx, "selector", sel)
holiman marked this conversation as resolved.
Show resolved Hide resolved
res, e := l.api.SignGnosisTx(ctx, addr, gnosisTx, methodSelector)
if res != nil {
l.log.Info("SignGnosisTx", "type", "response", "data", common.Bytes2Hex(res.Signature), "error", e)
} else {
l.log.Info("SignTransaction", "type", "response", "data", res, "error", e)
holiman marked this conversation as resolved.
Show resolved Hide resolved
}
return res, e
}

func (l *AuditLogger) SignTypedData(ctx context.Context, addr common.MixedcaseAddress, data TypedData) (hexutil.Bytes, error) {
l.log.Info("SignTypedData", "type", "request", "metadata", MetadataFromContext(ctx).String(),
"addr", addr.String(), "data", data)
Expand Down
91 changes: 91 additions & 0 deletions signer/core/gnosis_safe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package core

import (
"fmt"
"math/big"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/common/math"
)

// GnosisSafeTx is a type to parse the safe-tx returned by the relayer,
// it also conforms to the API required by the Gnosis Safe tx relay service.
// See 'SafeMultisigTransaction' on https://safe-transaction.mainnet.gnosis.io/
type GnosisSafeTx struct {
// These fields are only used on output
Signature hexutil.Bytes `json:"signature"`
SafeTxHash common.Hash `json:"contractTransactionHash"`
Sender common.MixedcaseAddress `json:"sender"`
// These fields are used both on input and output
Safe common.MixedcaseAddress `json:"safe"`
To common.MixedcaseAddress `json:"to"`
Value math.Decimal256 `json:"value"`
GasPrice math.Decimal256 `json:"gasPrice"`
Data *hexutil.Bytes `json:"data"`
Operation uint8 `json:"operation"`
GasToken common.Address `json:"gasToken"`
RefundReceiver common.Address `json:"refundReceiver"`
BaseGas big.Int `json:"baseGas"`
SafeTxGas big.Int `json:"safeTxGas"`
Nonce big.Int `json:"nonce"`
InputExpHash common.Hash `json:"safeTxHash"`
}

// ToTypedData converts the tx to a EIP-712 Typed Data structure for signing
func (tx *GnosisSafeTx) ToTypedData() TypedData {
var data hexutil.Bytes
if tx.Data != nil {
data = *tx.Data
Copy link
Member

Choose a reason for hiding this comment

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

this is not being tested

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you mean this line in particular or unit-testing of this file in general?

}
gnosisTypedData := TypedData{
Types: Types{
"EIP712Domain": []Type{{Name: "verifyingContract", Type: "address"}},
"SafeTx": []Type{
{Name: "to", Type: "address"},
{Name: "value", Type: "uint256"},
{Name: "data", Type: "bytes"},
{Name: "operation", Type: "uint8"},
{Name: "safeTxGas", Type: "uint256"},
{Name: "baseGas", Type: "uint256"},
{Name: "gasPrice", Type: "uint256"},
{Name: "gasToken", Type: "address"},
{Name: "refundReceiver", Type: "address"},
{Name: "nonce", Type: "uint256"},
},
},
Domain: TypedDataDomain{
VerifyingContract: tx.Safe.Address().Hex(),
},
PrimaryType: "SafeTx",
Message: TypedDataMessage{
"to": tx.To.Address().Hex(),
"value": tx.Value.String(),
"data": data,
"operation": fmt.Sprintf("%d", tx.Operation),
"safeTxGas": fmt.Sprintf("%#d", &tx.SafeTxGas),
"baseGas": fmt.Sprintf("%#d", &tx.BaseGas),
"gasPrice": tx.GasPrice.String(),
"gasToken": tx.GasToken.Hex(),
"refundReceiver": tx.RefundReceiver.Hex(),
"nonce": fmt.Sprintf("%d", tx.Nonce.Uint64()),
},
}
return gnosisTypedData
}

// ArgsForValidation returns a SendTxArgs struct, which can be used for the
// common validations, e.g. look up 4byte destinations
func (tx *GnosisSafeTx) ArgsForValidation() *SendTxArgs {
args := &SendTxArgs{
From: tx.Safe,
To: &tx.To,
Gas: hexutil.Uint64(tx.SafeTxGas.Uint64()),
GasPrice: hexutil.Big(tx.GasPrice),
Value: hexutil.Big(tx.Value),
Nonce: hexutil.Uint64(tx.Nonce.Uint64()),
Data: tx.Data,
Input: nil,
}
return args
}
34 changes: 22 additions & 12 deletions signer/core/signed_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ var typedDataReferenceTypeRegexp = regexp.MustCompile(`^[A-Z](\w*)(\[\])?$`)
//
// Note, the produced signature conforms to the secp256k1 curve R, S and V values,
// where the V value will be 27 or 28 for legacy reasons, if legacyV==true.
func (api *SignerAPI) sign(addr common.MixedcaseAddress, req *SignDataRequest, legacyV bool) (hexutil.Bytes, error) {
func (api *SignerAPI) sign(req *SignDataRequest, legacyV bool) (hexutil.Bytes, error) {
// We make the request prior to looking up if we actually have the account, to prevent
// account-enumeration via the API
res, err := api.UI.ApproveSignData(req)
Expand All @@ -136,7 +136,7 @@ func (api *SignerAPI) sign(addr common.MixedcaseAddress, req *SignDataRequest, l
return nil, ErrRequestDenied
}
// Look up the wallet containing the requested signer
account := accounts.Account{Address: addr.Address()}
account := accounts.Account{Address: req.Address.Address()}
wallet, err := api.am.Find(account)
if err != nil {
return nil, err
Expand Down Expand Up @@ -167,7 +167,7 @@ func (api *SignerAPI) SignData(ctx context.Context, contentType string, addr com
if err != nil {
return nil, err
}
signature, err := api.sign(addr, req, transformV)
signature, err := api.sign(req, transformV)
if err != nil {
api.UI.ShowError(err.Error())
return nil, err
Expand Down Expand Up @@ -312,28 +312,38 @@ func cliqueHeaderHashAndRlp(header *types.Header) (hash, rlp []byte, err error)

// SignTypedData signs EIP-712 conformant typed data
// hash = keccak256("\x19${byteVersion}${domainSeparator}${hashStruct(message)}")
// It returns
// - the signature,
// - and/or any error
func (api *SignerAPI) SignTypedData(ctx context.Context, addr common.MixedcaseAddress, typedData TypedData) (hexutil.Bytes, error) {
signature, _, err := api.signTypedData(ctx, addr, typedData)
return signature, err
}

// signTypedData is identical to the capitalized version, except that it also returns the hash (preimage)
// - the signature preimage (hash)
func (api *SignerAPI) signTypedData(ctx context.Context, addr common.MixedcaseAddress, typedData TypedData) (hexutil.Bytes, hexutil.Bytes, error) {
domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map())
if err != nil {
return nil, err
return nil, nil, err
}
typedDataHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message)
if err != nil {
return nil, err
return nil, nil, err
}
rawData := []byte(fmt.Sprintf("\x19\x01%s%s", string(domainSeparator), string(typedDataHash)))
sighash := crypto.Keccak256(rawData)
messages, err := typedData.Format()
if err != nil {
return nil, err
return nil, nil, err
}
req := &SignDataRequest{ContentType: DataTyped.Mime, Rawdata: rawData, Messages: messages, Hash: sighash}
signature, err := api.sign(addr, req, true)
req := &SignDataRequest{ContentType: DataTyped.Mime, Rawdata: rawData, Messages: messages, Hash: sighash, Address: addr}
signature, err := api.sign(req, true)
if err != nil {
api.UI.ShowError(err.Error())
return nil, err
return nil, nil, err
}
return signature, nil
return signature, sighash, nil
}

// HashStruct generates a keccak256 hash of the encoding of the provided data
Expand Down Expand Up @@ -420,8 +430,8 @@ func (typedData *TypedData) EncodeData(primaryType string, data map[string]inter
buffer := bytes.Buffer{}

// Verify extra data
if len(typedData.Types[primaryType]) < len(data) {
return nil, errors.New("there is extra data provided in the message")
if exp, got := len(typedData.Types[primaryType]), len(data); exp < got {
return nil, fmt.Errorf("there is extra data provided in the message (%d < %d)", exp, got)
}

// Add typehash
Expand Down
Loading