Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
**/typechain-types/
**/dist/
**/.next/
**/*.tsbuildinfo
**/vite.config.d.ts
**/vite.config.js

# Ignores development broadcast logs
!/broadcast
Expand Down
5 changes: 5 additions & 0 deletions packages/safe-tx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
"dependencies": {
"commander": "^12.0.0"
},
"optionalDependencies": {
"@ledgerhq/hw-app-eth": "^6.39.0",
"@ledgerhq/hw-transport-node-hid": "^6.29.5",
"@trezor/connect": "^9.4.0"
},
"peerDependencies": {
"viem": "^2.0.0"
},
Expand Down
101 changes: 101 additions & 0 deletions packages/safe-tx/scripts/test-trezor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bun
/**
* Graduated Trezor smoke test. Run with:
*
* bun packages/safe-tx/scripts/test-trezor.ts # Phase A only
* bun packages/safe-tx/scripts/test-trezor.ts --sign-harmless # Phase A + B
* bun packages/safe-tx/scripts/test-trezor.ts --sign-typed # Phase A + B + C
*
* Phases:
* A. Resolve address only — NO signature, no device prompt.
* Validates Trezor Bridge + @trezor/connect + USB connection.
*
* B. personal_sign of a fixed string — device shows the message,
* you press confirm. Signature is only valid for THAT exact
* string, cannot be replayed for anything dangerous.
*
* C. EIP-712 typed-data sign with a BOGUS verifyingContract
* (0x000…dEaD). Signature is bound to a non-existent contract,
* so even if leaked it's worthless on mainnet.
*
* Pre-flight:
* 1. Install Trezor Bridge: https://trezor.io/start
* 2. Plug + unlock your Trezor (any model).
* 3. bun add @trezor/connect (only needed for this test session)
*/

import { trezorSigner } from '../src/signers/trezor.js'

const args = process.argv.slice(2)
const wantHarmless = args.includes('--sign-harmless') || args.includes('--sign-typed')
const wantTyped = args.includes('--sign-typed')

console.log('=== Phase A — Resolve address (no signature) ===')
console.log('Initializing Trezor Connect (may take ~30s on first run)…')
const signer = await trezorSigner()
console.log(`✅ Address: ${signer.address}`)
console.log(' This came from your device without any prompt.')
console.log(' If you see this, the integration works end-to-end.\n')

if (!wantHarmless) {
console.log('Done. Re-run with --sign-harmless to test signature flow.')
process.exit(0)
}

console.log('=== Phase B — personal_sign a harmless string ===')
const message = `safe-tx Trezor smoke test @ ${new Date().toISOString()}`
console.log(`Message: "${message}"`)
console.log('LOOK AT YOUR TREZOR — confirm the message displayed matches.\n')
const sigB = await signer.signMessage({ message })
console.log(`✅ Signature: ${sigB}`)
console.log(' This signature is bound to that exact string only.')
console.log(' It cannot be replayed for any tx, transfer, or contract call.\n')

if (!wantTyped) {
console.log('Done. Re-run with --sign-typed to test EIP-712 flow.')
process.exit(0)
}

console.log('=== Phase C — EIP-712 sign with BOGUS verifyingContract ===')
const fakeSafe = '0x000000000000000000000000000000000000dEaD' as const
console.log(`verifyingContract: ${fakeSafe} (intentionally non-existent)`)
console.log('LOOK AT YOUR TREZOR — confirm the contract address shown is 0x000…dEaD.')
console.log('If it shows ANY OTHER address, REJECT the signature.\n')
const sigC = await signer.signTypedData({
domain: {
chainId: 1155,
verifyingContract: fakeSafe,
},
types: {
SafeTx: [
{ 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' },
],
},
primaryType: 'SafeTx',
message: {
to: fakeSafe,
value: 0n,
data: '0x',
operation: 0,
safeTxGas: 0n,
baseGas: 0n,
gasPrice: 0n,
gasToken: '0x0000000000000000000000000000000000000000',
refundReceiver: '0x0000000000000000000000000000000000000000',
nonce: 0n,
},
})
console.log(`✅ Signature: ${sigC}`)
console.log(' Bound to a non-existent Safe — useless on mainnet.\n')

console.log('All phases passed. Your Trezor signer is ready for real Safe ops.')
process.exit(0)
2 changes: 1 addition & 1 deletion packages/safe-tx/src/cli/commands/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function buildConfirmCommand(): Command {
.option('--network <name>', 'Target network', 'intuition-mainnet')
.addOption(
new Option('--signer <strategy>', 'Signer strategy')
.choices(['env', 'walletconnect', 'ledger'])
.choices(['env', 'walletconnect', 'ledger', 'trezor'])
.default('env'),
)
.action(
Expand Down
2 changes: 1 addition & 1 deletion packages/safe-tx/src/cli/commands/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function buildExecuteCommand(): Command {
.option('--network <name>', 'Target network', 'intuition-mainnet')
.addOption(
new Option('--signer <strategy>', 'Signer strategy for the executor (does not need to be a Safe owner)')
.choices(['env', 'walletconnect', 'ledger'])
.choices(['env', 'walletconnect', 'ledger', 'trezor'])
.default('env'),
)
.action(
Expand Down
2 changes: 1 addition & 1 deletion packages/safe-tx/src/cli/commands/propose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function buildProposeCommand(op: OpRegistration): Command {
cmd.option('--network <name>', 'Target network', 'intuition-mainnet')
cmd.addOption(
new Option('--signer <strategy>', 'Signer strategy')
.choices(['env', 'walletconnect', 'ledger'])
.choices(['env', 'walletconnect', 'ledger', 'trezor'])
.default('env'),
)

Expand Down
2 changes: 2 additions & 0 deletions packages/safe-tx/src/ops/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * as v2Admin from './v2-admin.js'
export * as factory from './factory.js'
export * as uups from './uups-upgrade.js'
export * as versionedProxy from './versioned-proxy.js'
export * as sponsored from './sponsored.js'
70 changes: 70 additions & 0 deletions packages/safe-tx/src/ops/sponsored.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { encodeFunctionData, type Address } from 'viem'
import type { AdminOp } from '../types.js'

/**
* Admin operations specific to `IntuitionFeeProxyV2Sponsored` (the
* sponsor-pool-funded variant). All gated by `onlyWhitelistedAdmin`.
*
* Note: `fundPool` is `external payable` and NOT admin-gated — anyone
* can credit the sponsor pool — so it's not modelled as an AdminOp.
*/
const SPONSORED_ABI = [
{
type: 'function',
name: 'setClaimLimits',
inputs: [
{ name: 'maxPerTx', type: 'uint256' },
{ name: 'maxPerWindow', type: 'uint256' },
{ name: 'maxVolumePerWindow', type: 'uint256' },
{ name: 'windowSec', type: 'uint256' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'reclaimFromPool',
inputs: [
{ name: 'amount', type: 'uint256' },
{ name: 'to', type: 'address' },
],
outputs: [],
stateMutability: 'nonpayable',
},
] as const

export function setClaimLimits(
proxy: Address,
maxPerTx: bigint,
maxPerWindow: bigint,
maxVolumePerWindow: bigint,
windowSec: bigint,
): AdminOp {
return {
to: proxy,
value: 0n,
data: encodeFunctionData({
abi: SPONSORED_ABI,
functionName: 'setClaimLimits',
args: [maxPerTx, maxPerWindow, maxVolumePerWindow, windowSec],
}),
description: `setClaimLimits(maxPerTx=${maxPerTx}, maxPerWindow=${maxPerWindow}, maxVolumePerWindow=${maxVolumePerWindow}, windowSec=${windowSec}) on ${proxy}`,
}
}

export function reclaimFromPool(
proxy: Address,
amount: bigint,
to: Address,
): AdminOp {
return {
to: proxy,
value: 0n,
data: encodeFunctionData({
abi: SPONSORED_ABI,
functionName: 'reclaimFromPool',
args: [amount, to],
}),
description: `reclaimFromPool(${amount} wei -> ${to}) on ${proxy}`,
}
}
99 changes: 99 additions & 0 deletions packages/safe-tx/src/ops/versioned-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { encodeFunctionData, type Address, type Hex } from 'viem'
import type { AdminOp } from '../types.js'

/**
* Role 1 (proxyAdmin) operations on `IntuitionVersionedFeeProxy`.
*
* `transferProxyAdmin` is 2-step: it sets `pendingProxyAdmin`, the
* target then has to call `acceptProxyAdmin` from their own wallet
* (or via their Safe propose flow if they're a Safe) to finalize.
*/
const VERSIONED_PROXY_ABI = [
{
type: 'function',
name: 'transferProxyAdmin',
inputs: [{ name: 'newAdmin', type: 'address' }],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'acceptProxyAdmin',
inputs: [],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'registerVersion',
inputs: [
{ name: 'version', type: 'bytes32' },
{ name: 'implementation', type: 'address' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'setDefaultVersion',
inputs: [{ name: 'version', type: 'bytes32' }],
outputs: [],
stateMutability: 'nonpayable',
},
] as const

export function transferProxyAdmin(proxy: Address, newAdmin: Address): AdminOp {
return {
to: proxy,
value: 0n,
data: encodeFunctionData({
abi: VERSIONED_PROXY_ABI,
functionName: 'transferProxyAdmin',
args: [newAdmin],
}),
description: `transferProxyAdmin(-> ${newAdmin}) on proxy ${proxy}`,
}
}

export function acceptProxyAdmin(proxy: Address): AdminOp {
return {
to: proxy,
value: 0n,
data: encodeFunctionData({
abi: VERSIONED_PROXY_ABI,
functionName: 'acceptProxyAdmin',
args: [],
}),
description: `acceptProxyAdmin() on proxy ${proxy}`,
}
}

export function registerVersion(
proxy: Address,
version: Hex,
implementation: Address,
): AdminOp {
return {
to: proxy,
value: 0n,
data: encodeFunctionData({
abi: VERSIONED_PROXY_ABI,
functionName: 'registerVersion',
args: [version, implementation],
}),
description: `registerVersion(${version}, ${implementation}) on proxy ${proxy}`,
}
}

export function setDefaultVersion(proxy: Address, version: Hex): AdminOp {
return {
to: proxy,
value: 0n,
data: encodeFunctionData({
abi: VERSIONED_PROXY_ABI,
functionName: 'setDefaultVersion',
args: [version],
}),
description: `setDefaultVersion(${version}) on proxy ${proxy}`,
}
}
8 changes: 6 additions & 2 deletions packages/safe-tx/src/signers/factory.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { envSigner, type EnvSignerOptions } from './env.js'
import { ledgerSigner, type LedgerSignerOptions } from './ledger.js'
import { trezorSigner, type TrezorSignerOptions } from './trezor.js'
import {
walletconnectSigner,
type WalletConnectSignerOptions,
} from './walletconnect.js'
import type { Signer } from './types.js'

export type SignerStrategy = 'env' | 'walletconnect' | 'ledger'
export type SignerStrategy = 'env' | 'walletconnect' | 'ledger' | 'trezor'

export type SignerOptions = {
env?: EnvSignerOptions
walletconnect?: WalletConnectSignerOptions
ledger?: LedgerSignerOptions
trezor?: TrezorSignerOptions
}

/**
* Resolve a Signer for the requested strategy. Async because some
* strategies (walletconnect, ledger) do I/O during initialization.
* strategies (walletconnect, ledger, trezor) do I/O during initialization.
*/
export async function getSigner(
strategy: SignerStrategy,
Expand All @@ -29,5 +31,7 @@ export async function getSigner(
return walletconnectSigner(opts.walletconnect)
case 'ledger':
return ledgerSigner(opts.ledger)
case 'trezor':
return trezorSigner(opts.trezor)
}
}
1 change: 1 addition & 0 deletions packages/safe-tx/src/signers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type { Signer } from './types.js'
export { envSigner, type EnvSignerOptions } from './env.js'
export { ledgerSigner, type LedgerSignerOptions } from './ledger.js'
export { trezorSigner, type TrezorSignerOptions } from './trezor.js'
export { walletconnectSigner, type WalletConnectSignerOptions } from './walletconnect.js'
export { getSigner, type SignerOptions, type SignerStrategy } from './factory.js'
Loading
Loading