Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/connect-common/src/callableMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const connectPublicCallableMethodGroups = {
'ethereumSignTransaction',
'ethereumSignMessage',
'ethereumSignTypedData',
'ethereumSignAuth7702',
'ethereumVerifyMessage',
],
cardano: [
Expand Down
34 changes: 34 additions & 0 deletions packages/connect-common/src/types/api/ethereum.type-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,37 @@ export const signTypedData = async (api: TrezorConnect) => {
domain_separator_hash: '0x',
});
};

export const signAuth7702 = async (api: TrezorConnect) => {
const signed = await api.ethereumSignAuth7702({
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b',
nonce: 0,
__experimental: true,
});

if (signed.success) {
const { payload } = signed;
payload.yParity.toFixed();
payload.r.toLowerCase();
payload.s.toLowerCase();
}

// @ts-expect-error: `__experimental` opt-in is missing
await api.ethereumSignAuth7702({
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b',
nonce: 0,
});

await api.ethereumSignAuth7702({
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b',
// @ts-expect-error: nonce is a number
nonce: '0x0',
__experimental: true,
});
};
35 changes: 35 additions & 0 deletions packages/connect-common/src/types/api/ethereum/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ import { Type } from '@trezor/schema-utils';

import { DerivationPath } from '../../params';

// ethereumSignAuth7702

/**
* EIP-7702 authorization tuple to be signed by the device.
*/
export type EthereumSignAuth7702 = Static<typeof EthereumSignAuth7702>;
export const EthereumSignAuth7702 = Type.Object({
path: DerivationPath,
/** Chain id the authorization is valid on. `0` makes it valid on every EVM chain. */
chainId: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
/**
* Address of the contract the account delegates to. Authorizing the zero address
* revokes an existing delegation.
*/
delegate: Type.String(),
/**
* Account nonce the authorization is valid for. Both fields are `uint64` on the wire, but
* are capped at `Number.MAX_SAFE_INTEGER` so a value JavaScript cannot represent exactly is
* rejected instead of being silently rounded into a different authorization.
*/
nonce: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
});

/**
* Signature of an EIP-7702 authorization tuple. Together with the `chainId`, `delegate`
* and `nonce` that were signed it forms an entry of a transaction `authorizationList`.
*/
export type EthereumSignedAuth7702 = Static<typeof EthereumSignedAuth7702>;
export const EthereumSignedAuth7702 = Type.Object({
/** Parity of the signature `y` coordinate, `0` or `1`. Legacy `v` is `yParity + 27`. */
yParity: Type.Number(),
r: Type.String(),
s: Type.String(),
});

// ethereumSignMessage

export type EthereumSignMessage = Static<typeof EthereumSignMessage>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { EthereumSignAuth7702, EthereumSignedAuth7702 } from './common';
import type { ExperimentalMethod, Params, Response } from '../../params';

export declare function ethereumSignAuth7702(
params: Params<EthereumSignAuth7702 & ExperimentalMethod>,
): Response<EthereumSignedAuth7702>;
2 changes: 2 additions & 0 deletions packages/connect-common/src/types/api/ethereum/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Type } from '@trezor/schema-utils';

import type { ethereumGetAddress } from './ethereumGetAddress';
import type { ethereumGetPublicKey } from './ethereumGetPublicKey';
import type { ethereumSignAuth7702 } from './ethereumSignAuth7702';
import type { ethereumSignMessage } from './ethereumSignMessage';
import type { ethereumSignTransaction } from './ethereumSignTransaction';
import type { ethereumSignTypedData } from './ethereumSignTypedData';
Expand All @@ -15,6 +16,7 @@ export const TrezorConnectEthereum = Type.Object({
ethereumSignTransaction: Type.Unsafe<typeof ethereumSignTransaction>(),
ethereumSignMessage: Type.Unsafe<typeof ethereumSignMessage>(),
ethereumSignTypedData: Type.Unsafe<typeof ethereumSignTypedData>(),
ethereumSignAuth7702: Type.Unsafe<typeof ethereumSignAuth7702>(),
ethereumVerifyMessage: Type.Unsafe<typeof ethereumVerifyMessage>(),
});
export type TrezorConnectEthereum = Static<typeof TrezorConnectEthereum>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export default [
{
name: 'ethereumSignAuth7702',
submitButton: 'Sign authorization',

fields: [
{
name: 'path',
type: 'input',
value: `m/44'/60'/0'/0/0`,
},
{
name: 'chainId',
type: 'number',
value: '1',
},
{
// MetaMask delegate, one of the contracts allowed by firmware. Use the zero
// address to revoke an existing delegation instead.
name: 'delegate',
type: 'input',
value: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b',
},
{
name: 'nonce',
type: 'number',
value: '0',
},
{
name: '__experimental',
type: 'checkbox',
value: true,
},
],
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Callout } from 'nextra/components';

import {
EthereumSignAuth7702,
EthereumSignedAuth7702,
} from '@trezor/connect-common/src/types/api/ethereum/common';

import { ApiPlayground } from '../../../components/ApiPlayground';
import { CommonParamsLink } from '../../../components/CommonParamsLink';
import { ParamsTable } from '../../../components/ParamsTable';
import signAuth7702 from '../../../data/methods/ethereum/signAuth7702.ts';

<ApiPlayground options={[{ title: 'Sign authorization', legacyConfig: signAuth7702[0] }]} />

export const paramDescriptions = {
path: 'minimum length is `3`. [read more](/details/path)',
chainId: 'chain id the authorization is valid on. `0` makes it valid on every EVM chain.',
delegate:
'address of the contract the account delegates to. Use `0x0000000000000000000000000000000000000000` to revoke an existing delegation. A mixed-case address must carry a valid [EIP-55](https://eips.ethereum.org/EIPS/eip-55) checksum.',
nonce: 'account nonce the authorization is valid for.',
};

## Ethereum: sign EIP-7702 authorization

Asks device to sign an [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization tuple,
delegating the account derived from the given BIP32 path to a smart contract. Signing an
authorization for the zero address revokes an existing delegation instead.

```javascript
const result = await TrezorConnect.ethereumSignAuth7702(params);
```

<Callout type="warning">
**Experimental method**, opted into by passing `__experimental: true`. It may change without a
major version bump.
</Callout>

### Device requirements

Available since firmware 2.12.4; T1B1 does not support it at all. The device also has to be
configured up front, using [trezorctl](https://trezor.io/guides/trezorctl):

```bash
# Required for every call - the underlying protobuf message is experimental.
trezorctl set experimental-features on

# Required to authorize a delegate. Revocation works under the default strict checks.
trezorctl set safety-checks prompt
```

Firmware accepts only a
[short allowlist of delegate contracts](https://github.com/trezor/trezor-firmware/blob/core/v2.12.4/core/src/apps/ethereum/sc_constants.py#L76)
β€” currently Ambire and MetaMask. Any other delegate is rejected with
`Unknown EIP-7702 delegate address`.

### Params

<CommonParamsLink />

#### EthereumSignAuth7702

<ParamsTable schema={EthereumSignAuth7702} descriptions={paramDescriptions} />

### Example

Delegate the first Ethereum account to a smart contract:

```javascript
TrezorConnect.ethereumSignAuth7702({
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b',
nonce: 0,
__experimental: true,
});
```

Revoke the delegation:

```javascript
TrezorConnect.ethereumSignAuth7702({
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: '0x0000000000000000000000000000000000000000',
nonce: 1,
__experimental: true,
});
```

### Result

<ParamsTable schema={EthereumSignedAuth7702} />

```javascript
{
success: true,
payload: {
yParity: number, // 0 or 1, legacy `v` is `yParity + 27`
r: string, // hexadecimal string with "0x" prefix
s: string, // hexadecimal string with "0x" prefix
}
}
```

Together with the signed `chainId`, `delegate` and `nonce` this forms one entry of a transaction
`authorizationList`.

Error

```javascript
{
success: false,
error: {
message: string // error message
}
}
```
81 changes: 81 additions & 0 deletions packages/connect/e2e/__fixtures__/ethereumSignAuth7702.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
import commonFixtures from '../../../../submodules/trezor-common/tests/fixtures/ethereum/sign_auth_eip7702.json';
// @ts-ignore
import errorFixtures from '../../../../submodules/trezor-common/tests/fixtures/ethereum/sign_auth_eip7702_errors.json';

// EIP-7702 is an experimental message not implemented on T1B1 and added to firmware in 2.12.4,
// so it is skipped on T1B1 and on older firmware.
const skip = ['1', '<2.12.4'];

// The signed digest covers only chain id, delegate and nonce, so the upstream signatures hold
// whether or not network definitions are downloaded - definitions only change what the device
// displays. That includes the `_defs` fixtures, where the firmware test suite injects fake
// definitions we have no equivalent for.
const ethereumSignAuth7702: TestCase = {
method: 'ethereumSignAuth7702',
setup: {
mnemonic: commonFixtures.setup.mnemonic,
settings: {
experimental_features: true,
// Authorizing a delegate is refused under strict safety checks. Revocation is not,
// but the whole test case shares one emulator setup.
safety_checks: 2,
},
},
tests: [
...commonFixtures.tests.map(({ name, parameters, result }) => ({
description: name,
params: {
__experimental: true,
path: parameters.path,
chainId: parameters.chain_id,
delegate: parameters.delegate,
nonce: parameters.nonce,
},
result: {
yParity: result.sig_v,
r: `0x${result.sig_r}`,
s: `0x${result.sig_s}`,
},
skip,
})),
...errorFixtures.tests.map(({ name, parameters }) => ({
description: `${name} => rejected`,
params: {
__experimental: true,
path: parameters.path,
chainId: parameters.chain_id,
delegate: parameters.delegate,
nonce: parameters.nonce,
},
result: false,
skip,
})),
{
description: 'missing __experimental opt-in',
params: {
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b',
nonce: 1,
},
result: false,
skip,
},
{
description: 'delegate is not an address',
params: {
__experimental: true,
path: "m/44'/60'/0'/0/0",
chainId: 1,
delegate: 'not-an-address',
nonce: 1,
},
result: false,
skip,
},
],
};

export default ethereumSignAuth7702;
1 change: 1 addition & 0 deletions packages/connect/e2e/__fixtures__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { default as changeLanguage } from './changeLanguage';
export { default as composeTransaction } from './composeTransaction';
export { default as ethereumGetAddress } from './ethereumGetAddress';
export { default as ethereumGetPublicKey } from './ethereumGetPublicKey';
export { default as ethereumSignAuth7702 } from './ethereumSignAuth7702';
export { default as ethereumSignMessage } from './ethereumSignMessage';
export { default as ethereumSignTransaction } from './ethereumSignTransaction';
export { default as ethereumSignTransactionEip155 } from './ethereumSignTransactionEip155';
Expand Down
Loading
Loading