diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd0ec1c..0a6ad8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Freedom will be documented in this file. +## [Unreleased] + +### Changed + +- Updated bundled [Ant](https://github.com/solardev-xyz/ant) to 0.5.36: fixes the ~250 MiB upload stall, adds upload-side Reed-Solomon encoding, end-to-end Swarm content encryption, local pinning, and ACT access control +- The Swarm node's API now comes up instantly on start, so the node menu shows peers counting up live instead of sitting at 0 during startup + ## [0.8.0] - 2026-07-02 ### Added diff --git a/package-lock.json b/package-lock.json index 94cf0c7e..2371d7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MPL-2.0", "dependencies": { "@adraffy/ens-normalize": "^1.11.1", - "@corpus-core/colibri-stateless": "^1.1.30", + "@corpus-core/colibri-stateless": "^2.0.0", "@ensdomains/content-hash": "^3.0.0", "@ethersphere/bee-js": "^12.2.1", "@ledgerhq/hw-app-eth": "^7.8.8", @@ -19,6 +19,7 @@ "@metamask/browser-passworder": "^6.0.0", "@openlv/core": "^0.0.2", "@openlv/session": "^0.0.3", + "@safe-global/protocol-kit": "^8.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", @@ -1539,9 +1540,9 @@ "license": "MIT" }, "node_modules/@corpus-core/colibri-stateless": { - "version": "1.1.30", - "resolved": "https://registry.npmjs.org/@corpus-core/colibri-stateless/-/colibri-stateless-1.1.30.tgz", - "integrity": "sha512-U/oFj14tKfGZUESGxEegJt1d8B0OZYGclXUQl8HIN5ttTUS0QQML//DPvR9k0PsVU9xz7ZWFt3B4pk0Ij6P/Kg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@corpus-core/colibri-stateless/-/colibri-stateless-2.0.0.tgz", + "integrity": "sha512-l+hYO+fu1J655iBLfStehm066wD1EgBshSoumP6hlCkFTIcWRMpJPCpyxjqnqUG/Qe4r+m/nvCbr5RWEIrxHZw==", "license": "MIT" }, "node_modules/@electron-internal/extract-zip": { @@ -4517,6 +4518,68 @@ } } }, + "node_modules/@safe-global/protocol-kit": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-8.0.3.tgz", + "integrity": "sha512-XKtpfalQQXzfcLq3BNcbCfqJt6ScdL6AZNsefxVSEujrPSY19K+GWmX3yHskbPE8Z9pI07OtJOOpypfLhsFr8Q==", + "license": "MIT", + "dependencies": { + "@safe-global/safe-deployments": "^1.37.59", + "@safe-global/safe-modules-deployments": "^3.0.7", + "@safe-global/types-kit": "^4.0.1", + "abitype": "^1.2.3", + "semver": "^7.8.0", + "viem": "^2.52.2" + }, + "optionalDependencies": { + "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13" + } + }, + "node_modules/@safe-global/protocol-kit/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@safe-global/safe-deployments": { + "version": "1.37.59", + "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.59.tgz", + "integrity": "sha512-y1eAviyDJARMqwXctqXylsBGqNkeFIq/q2XJlmkVZ9vSwfQBP31a33sUZkbmwwJuomv0/Yrl04YNDQ2C8CaGWw==", + "license": "MIT", + "dependencies": { + "semver": "^7.6.2" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.16.0" + } + }, + "node_modules/@safe-global/safe-modules-deployments": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@safe-global/safe-modules-deployments/-/safe-modules-deployments-3.0.7.tgz", + "integrity": "sha512-XTloEuDvKBQJCKoySg1Y+NMdVFJWxwhH2fSaziT8R4vIbuEyQYWW9vj4z92+hHPXCBBh0cyi/MBXW+HYjYroew==", + "license": "MIT" + }, + "node_modules/@safe-global/types-kit": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-4.0.1.tgz", + "integrity": "sha512-zmIYyAH9mcBcqHszPgcfNjOJYuPSvWCcc/f8zeznh7N1HSA7jEoFErO06O4QDfwAAS4aEyoyjhPyfHbVYRheZg==", + "license": "MIT", + "dependencies": { + "abitype": "^1.2.3" + } + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", diff --git a/package.json b/package.json index c9816215..162a429b 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ }, "dependencies": { "@adraffy/ens-normalize": "^1.11.1", - "@corpus-core/colibri-stateless": "^1.1.30", + "@corpus-core/colibri-stateless": "^2.0.0", "@ensdomains/content-hash": "^3.0.0", "@ethersphere/bee-js": "^12.2.1", "@ledgerhq/hw-app-eth": "^7.8.8", @@ -210,6 +210,7 @@ "@metamask/browser-passworder": "^6.0.0", "@openlv/core": "^0.0.2", "@openlv/session": "^0.0.3", + "@safe-global/protocol-kit": "^8.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", diff --git a/scripts/fetch-ant.js b/scripts/fetch-ant.js index eabb2e42..1efba56a 100644 --- a/scripts/fetch-ant.js +++ b/scripts/fetch-ant.js @@ -15,7 +15,7 @@ const ANT_REPO = process.env.ANT_REPO || 'solardev-xyz/ant'; // could ship a different Ant than CI validated. Override via ANT_RELEASE_TAG // for local testing of newer releases; set it to `latest` to resolve the // repo's most recent published release. -const PINNED_RELEASE_TAG = 'v0.5.33'; +const PINNED_RELEASE_TAG = 'v0.5.41'; // In-repo trust root for the pinned release: the sha256 of its SHA256SUMS // asset, recorded at pin time (trust-on-first-use by the author). The release // downloads its SHA256SUMS from the same GitHub release as the binaries, so @@ -24,7 +24,7 @@ const PINNED_RELEASE_TAG = 'v0.5.33'; // that tampering detectable. Update alongside PINNED_RELEASE_TAG on every // deliberate bump: `shasum -a 256` the freshly downloaded SHA256SUMS. const PINNED_SHA256SUMS_DIGEST = - '4647fb92f635ded61de1afda45fcb72766c93138ae8d2019eb56ec93e226df9d'; + 'c5138937118f0c728f3fbf52629bd021264a730323ea67d257a80fc042cd2198'; const ANT_RELEASE_TAG = process.env.ANT_RELEASE_TAG || PINNED_RELEASE_TAG; function fetchReleaseOnce() { diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index 5ace287a..76afa5f3 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -794,14 +794,31 @@ const WALLET_TYPES = { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', // phone / other device signing over openlv + SAFE: 'safe', // Safe smart account owned by other wallet records }; -/** User-facing labels for device account types (auto-names, error text). */ +/** User-facing labels for non-mnemonic account types (auto-names, error text). */ const DEVICE_LABELS = { [WALLET_TYPES.LEDGER]: 'Ledger', [WALLET_TYPES.REMOTE]: 'Phone', + [WALLET_TYPES.SAFE]: 'Safe', }; +/** Type-specific record fields to expose through the record seams. */ +function extraRecordFields(record) { + const fields = {}; + if (record.path) { + fields.path = record.path; + } + if (record.type === WALLET_TYPES.SAFE) { + fields.owners = record.owners; + fields.threshold = record.threshold; + fields.saltNonce = record.saltNonce; + fields.deployed = record.deployed || {}; + } + return fields; +} + /** * The wallet list stored in vault-meta, with the implicit pre-multi-wallet * default (just the main wallet) when `derivedWallets` was never written. @@ -846,7 +863,7 @@ function getWalletRecord(walletIndex, meta = getVaultMeta()) { name: record.name, address, type: record.type || WALLET_TYPES.MNEMONIC, - ...(record.path ? { path: record.path } : {}), + ...extraRecordFields(record), }; } @@ -913,7 +930,7 @@ async function getDerivedWallets() { name: wallet.name, address, type, - ...(wallet.path ? { path: wallet.path } : {}), + ...extraRecordFields(wallet), }); } @@ -999,6 +1016,79 @@ async function addRemoteWallet(name, address) { return addDeviceWallet(WALLET_TYPES.REMOTE, name, address); } +/** + * Add a Safe smart-account record. + * + * The init params (owners, threshold, saltNonce) are FROZEN once stored — + * they are what makes the CREATE2 address reproducible on other chains + * (retroactive deployment recovers funds sent there), so nothing may ever + * rewrite them. `owners` are wallet indexes of existing records; the + * caller (safe-service) resolves their addresses and predicts `address` + * before storing. + * + * Only the shipped presets are accepted: 1-of-2 and 2-of-3. 2-of-2 is + * deliberately not offered — losing either device bricks the funds. + * + * @param {string} name - Display name ('' → auto "Safe N") + * @param {Object} params + * @param {string} params.address - Predicted counterfactual address + * @param {number[]} params.owners - Wallet indexes of the owner records + * @param {number} params.threshold + * @param {string} params.saltNonce + * @returns {Promise} The stored record + */ +async function addSafeWallet(name, { address, owners, threshold, saltNonce }) { + const validPreset = + Array.isArray(owners) && + ((owners.length === 2 && threshold === 1) || (owners.length === 3 && threshold === 2)); + if (!validPreset) { + throw new Error('A Safe needs 1 of 2 or 2 of 3 owners'); + } + if (new Set(owners).size !== owners.length) { + throw new Error('Duplicate owner accounts'); + } + for (const ownerIndex of owners) { + const record = getWalletRecord(ownerIndex); + if (!record) { + throw new Error(`Owner wallet index ${ownerIndex} does not exist`); + } + if (record.type === WALLET_TYPES.SAFE) { + throw new Error('A Safe cannot own another Safe'); + } + } + if (typeof saltNonce !== 'string' || !/^\d+$/.test(saltNonce)) { + throw new Error('Invalid Safe salt nonce'); + } + + return addDeviceWallet(WALLET_TYPES.SAFE, name, address, { + owners: [...owners], + threshold, + saltNonce, + deployed: {}, + }); +} + +/** + * Record that a Safe's contract is now live on a chain. Deployment state + * is the ONLY mutable part of a safe record — init params stay frozen. + * + * @param {number} index - Wallet index of the safe record + * @param {number} chainId + */ +async function markSafeDeployed(index, chainId) { + const meta = getVaultMeta(); + if (!meta) { + throw new Error('No vault found'); + } + const wallets = getWalletList(meta); + const record = wallets.find((w) => w.index === index); + if (!record || record.type !== WALLET_TYPES.SAFE) { + throw new Error(`Wallet ${index} is not a Safe account`); + } + record.deployed = { ...(record.deployed || {}), [chainId]: true }; + saveVaultMeta({ ...meta, derivedWallets: wallets }); +} + /** * Get the active wallet index * @returns {number} @@ -1149,6 +1239,29 @@ async function deleteDerivedWallet(index) { throw err; } + // Safe owners are referenced by index; deleting one would leave the + // Safe unable to collect that signature (and break executor selection). + const owningSafe = wallets.find( + (w) => w.type === WALLET_TYPES.SAFE && (w.owners || []).includes(index) + ); + if (owningSafe) { + throw new Error( + `This account is an owner of "${owningSafe.name}" — delete that Safe account first` + ); + } + + // A Safe's half-signed state is keyed by wallet index (safe-pending.json + // entry, in-memory SafeMessage session). Discard both WITH the record: + // a later account that reuses the index must neither inherit nor be + // blocked by the deleted Safe's leftovers. Cleanup precedes the meta + // write so a failure never leaves a deleted record with live state. + // (Lazy requires — both modules are dependency-light — keep the Safe + // stack out of ordinary wallet operations.) + if (wallets[walletIndex].type === WALLET_TYPES.SAFE) { + require('./wallet/safe/message-sessions').discardSession(index); + require('./wallet/safe/pending-store').clearPending(index); + } + // Remove from list wallets.splice(walletIndex, 1); @@ -1521,6 +1634,8 @@ module.exports = { createDerivedWallet, addLedgerWallet, addRemoteWallet, + addSafeWallet, + markSafeDeployed, renameDerivedWallet, deleteDerivedWallet, getActiveWalletAddress, diff --git a/src/main/identity-manager.test.js b/src/main/identity-manager.test.js index 78ef94ac..2f5f1770 100644 --- a/src/main/identity-manager.test.js +++ b/src/main/identity-manager.test.js @@ -404,6 +404,207 @@ describe('identity-manager ledger accounts', () => { }); }); +describe('identity-manager safe accounts', () => { + let tmpDir; + let envSnapshot; + let identityManager; + + const SAFE_ADDRESS = '0x41aD4887971f90BB3fE4d83eCa65177281283261'; + const LEDGER_ADDRESS = '0x209693Bc6afc0C5328bA36FaF03C514EF312287C'; + const SALT = '20260710'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-manager-safe-')); + envSnapshot = snapshotEnv(); + process.env.FREEDOM_IDENTITY_DATA = tmpDir; + identityManager = loadMainModule(require.resolve('./identity-manager'), { + userDataDir: tmpDir, + extraMocks: { + [require.resolve('./identity')]: () => ({ + getMnemonic: jest.fn(() => null), // vault locked — safe ops must not need it + isUnlocked: jest.fn(() => false), + }), + }, + }).mod; + }); + + afterEach(() => { + restoreEnv(envSnapshot); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeVaultMeta(meta) { + fs.writeFileSync(path.join(tmpDir, 'vault-meta.json'), JSON.stringify(meta, null, 2), 'utf-8'); + } + + function readVaultMeta() { + return JSON.parse(fs.readFileSync(path.join(tmpDir, 'vault-meta.json'), 'utf-8')); + } + + async function seedOwners() { + writeVaultMeta({ + activeWalletIndex: 0, + addresses: { userWallet: '0x0000000000000000000000000000000000000001' }, + derivedWallets: [ + { index: 0, name: 'Main Wallet', address: '0x0000000000000000000000000000000000000001' }, + ], + }); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, "44'/60'/0'/0/0"); + } + + const addSafe = (name = 'Joint', overrides = {}) => + identityManager.addSafeWallet(name, { + address: SAFE_ADDRESS, + owners: [0, 1], + threshold: 1, + saltNonce: SALT, + ...overrides, + }); + + test('addSafeWallet appends a typed record with init params frozen in vault-meta', async () => { + await seedOwners(); + + const wallet = await addSafe(); + + expect(wallet).toEqual({ + index: 2, + name: 'Joint', + address: SAFE_ADDRESS, + type: 'safe', + owners: [0, 1], + threshold: 1, + saltNonce: SALT, + deployed: {}, + }); + expect(readVaultMeta().derivedWallets[2]).toMatchObject({ + type: 'safe', + owners: [0, 1], + threshold: 1, + saltNonce: SALT, + }); + }); + + test('addSafeWallet auto-names and works with the vault locked', async () => { + await seedOwners(); + const wallet = await addSafe(''); + expect(wallet.name).toBe('Safe 1'); + }); + + test('addSafeWallet enforces the 1/2 and 2/3 presets only', async () => { + await seedOwners(); + await identityManager.addRemoteWallet('My Phone', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); + + // 2/2 is deliberately not offered (lose either device → bricked) + await expect(addSafe('Bad', { threshold: 2 })).rejects.toThrow(/1 of 2 or 2 of 3/); + await expect(addSafe('Bad', { owners: [0, 1, 2], threshold: 3 })).rejects.toThrow(/1 of 2 or 2 of 3/); + await expect(addSafe('Bad', { owners: [0], threshold: 1 })).rejects.toThrow(/1 of 2 or 2 of 3/); + // valid 2/3 + await expect(addSafe('Resilient', { owners: [0, 1, 2], threshold: 2 })).resolves.toMatchObject({ + threshold: 2, + }); + }); + + test('addSafeWallet rejects unknown, duplicate, and safe owners', async () => { + await seedOwners(); + + await expect(addSafe('Bad', { owners: [0, 99] })).rejects.toThrow(/99/); + await expect(addSafe('Bad', { owners: [0, 0] })).rejects.toThrow(/duplicate/i); + + const safe = await addSafe(); + await expect( + addSafe('Nested', { + address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + owners: [0, safe.index], + }) + ).rejects.toThrow(/cannot own/i); + }); + + test('record seams expose the safe fields', async () => { + await seedOwners(); + const wallet = await addSafe(); + + expect(identityManager.getWalletRecord(wallet.index)).toMatchObject({ + type: 'safe', + address: SAFE_ADDRESS, + owners: [0, 1], + threshold: 1, + saltNonce: SALT, + deployed: {}, + }); + + const wallets = await identityManager.getDerivedWallets(); + expect(wallets[2]).toMatchObject({ type: 'safe', owners: [0, 1], threshold: 1 }); + + await expect(identityManager.getUserWalletKey(wallet.index)) + .rejects.toThrow('This account has no derivable private key'); + }); + + test('markSafeDeployed persists per-chain deployment state', async () => { + await seedOwners(); + const wallet = await addSafe(); + + await identityManager.markSafeDeployed(wallet.index, 100); + + expect(identityManager.getWalletRecord(wallet.index).deployed).toEqual({ 100: true }); + expect(readVaultMeta().derivedWallets[2].deployed).toEqual({ 100: true }); + + await expect(identityManager.markSafeDeployed(0, 100)).rejects.toThrow(/not a Safe/i); + }); + + test('an owner of a safe cannot be deleted while the safe exists', async () => { + await seedOwners(); + const safe = await addSafe(); + + await expect(identityManager.deleteDerivedWallet(1)).rejects.toThrow(/owner of "Joint"/); + + // deleting the safe frees its owners + await identityManager.deleteDerivedWallet(safe.index); + await expect(identityManager.deleteDerivedWallet(1)).resolves.toBeUndefined(); + }); + + test('deleting a Safe discards its pending SafeTx and live message session', async () => { + await seedOwners(); + const safe = await addSafe(); + + // Half-signed leftovers are keyed by the Safe's WALLET INDEX — they + // must go with the record, or a later account reusing the index + // would inherit (or be blocked by) them. + const { setPending, getPending } = require('./wallet/safe/pending-store'); + const messageSessions = require('./wallet/safe/message-sessions'); + setPending(safe.index, { safeTxHash: '0xdead', threshold: 1, signatures: [] }); + const detach = jest.fn(); + messageSessions.setSession(safe.index, { token: 'session-token', detach }); + + await identityManager.deleteDerivedWallet(safe.index); + + expect(getPending(safe.index)).toBeNull(); + expect(messageSessions.getSession(safe.index)).toBeNull(); + expect(detach).toHaveBeenCalled(); // webContents lifecycle listeners unhooked + // the on-disk store no longer carries the entry either + const pendingFile = path.join(tmpDir, 'safe-pending.json'); + expect(JSON.parse(fs.readFileSync(pendingFile, 'utf-8'))).toEqual({}); + }); + + test('an account created after deleting the highest-index Safe starts with a clean slate', async () => { + await seedOwners(); + const safe = await addSafe(); // highest index in the vault + + const { setPending, getPending } = require('./wallet/safe/pending-store'); + setPending(safe.index, { safeTxHash: '0xdead', threshold: 1, signatures: [] }); + + await identityManager.deleteDerivedWallet(safe.index); + + // new wallet indexes are max+1 — the successor REUSES the freed index + const successor = await identityManager.addRemoteWallet( + 'New Phone', + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + ); + expect(successor.index).toBe(safe.index); + expect(getPending(successor.index)).toBeNull(); + expect(require('./wallet/safe/message-sessions').getSession(successor.index)).toBeNull(); + }); +}); + /** * Regression guard for issue #90: Bee's restart after (re)injection is owned by * injectBeeIdentity via the lifecycle hook (stop → wipe → start), so Bee must diff --git a/src/main/payment-history.js b/src/main/payment-history.js index 5a6da7d7..6e449146 100644 --- a/src/main/payment-history.js +++ b/src/main/payment-history.js @@ -25,7 +25,13 @@ const DB_FILE = 'payment-history.sqlite'; const LEGACY_JSON_FILE = 'x402-receipts.json'; const MIGRATED_SUFFIX = '.migrated'; -const KINDS = Object.freeze({ X402: 'x402', WALLET_SEND: 'wallet-send', DAPP_SEND: 'dapp-send' }); +const KINDS = Object.freeze({ + X402: 'x402', + WALLET_SEND: 'wallet-send', + DAPP_SEND: 'dapp-send', + SAFE_SEND: 'safe-send', + SAFE_DEPLOY: 'safe-deploy', +}); const STATUSES = Object.freeze({ PENDING: 'pending', CONFIRMED: 'confirmed', diff --git a/src/main/preload.js b/src/main/preload.js index b832082b..d52099aa 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -411,6 +411,33 @@ contextBridge.exposeInMainWorld('wallet', { // RPC proxy (renderer CSP blocks direct fetch to external endpoints) proxyRpc: (rpcUrl, method, params) => ipcRenderer.invoke('wallet:proxy-rpc', { rpcUrl, method, params }), + + // Safe multisig accounts + createSafe: (name, ownerIndexes, threshold) => + ipcRenderer.invoke('wallet:create-safe', name, ownerIndexes, threshold), + getSafeStatus: (index) => ipcRenderer.invoke('wallet:get-safe-status', index), + activateSafe: (index) => ipcRenderer.invoke('wallet:activate-safe', index), + // Safe sends (the signing board): every call returns {success, state} + // where state is the board's render model (null when nothing pending). + safeSend: (safeIndex, tx, display) => ipcRenderer.invoke('wallet:safe-send', safeIndex, tx, display), + safeSign: (safeIndex, ownerIndex) => ipcRenderer.invoke('wallet:safe-sign', safeIndex, ownerIndex), + safeExecute: (safeIndex) => ipcRenderer.invoke('wallet:safe-execute', safeIndex), + safeState: (safeIndex) => ipcRenderer.invoke('wallet:safe-state', safeIndex), + safeCancelPending: (index) => ipcRenderer.invoke('wallet:safe-cancel-pending', index), + safePendingList: () => ipcRenderer.invoke('wallet:safe-pending-list'), + // SafeMessage sessions (dApp message signing via EIP-1271). start + // binds the session to the requesting page ({origin, webContentsId}) + // and returns state.token — required by every other call. + safeMessageStart: (safeIndex, request, display, requester) => + ipcRenderer.invoke('wallet:safe-message-start', safeIndex, request, display, requester), + safeMessageSign: (safeIndex, ownerIndex, token) => + ipcRenderer.invoke('wallet:safe-message-sign', safeIndex, ownerIndex, token), + safeMessageState: (safeIndex, token) => + ipcRenderer.invoke('wallet:safe-message-state', safeIndex, token), + safeMessageCancel: (safeIndex, token) => + ipcRenderer.invoke('wallet:safe-message-cancel', safeIndex, token), + safeMessageComplete: (safeIndex, token) => + ipcRenderer.invoke('wallet:safe-message-complete', safeIndex, token), }); contextBridge.exposeInMainWorld('ledger', { diff --git a/src/main/wallet/provider-manager.js b/src/main/wallet/provider-manager.js index 15c51dbc..81865fbd 100644 --- a/src/main/wallet/provider-manager.js +++ b/src/main/wallet/provider-manager.js @@ -12,6 +12,7 @@ const registry = require('../networks/network-registry'); // Cache providers by chain ID const providerCache = new Map(); +const eip1193Cache = new Map(); // Provider configuration const PROVIDER_CONFIG = { @@ -22,41 +23,79 @@ const PROVIDER_CONFIG = { }; /** - * Create a FallbackProvider for a given chain + * The chain's rpc-role endpoint pool as JsonRpcProviders: keyless builtin + * RPCs plus any keyed provider whose API key is configured, first URL + * being the preferred one. */ -function createFallbackProvider(chainId) { - const chain = getChain(chainId); - if (!chain) { - throw new Error(`Unsupported chain ID: ${chainId}`); - } - - // The registry resolves the chain's rpc-role endpoint pool: keyless - // builtin RPCs plus any keyed provider whose API key is configured. +function createProviderPool(chainId) { const rpcUrls = registry.getEndpoints(chainId, 'rpc'); if (rpcUrls.length === 0) { + const chain = getChain(chainId); throw new Error( - `No RPC endpoints available for ${chain.name}. ` + + `No RPC endpoints available for ${chain ? chain.name : `chain ${chainId}`}. ` + 'Please configure an RPC provider (Alchemy, Infura, or DRPC) in Settings.' ); } - // Create providers from RPC URLs with priority - const providers = rpcUrls.map((url, index) => ({ - provider: new JsonRpcProvider(url, chainId, { - staticNetwork: true, - batchMaxCount: 1, // Disable batching for public RPCs - }), + return rpcUrls.map( + (url) => + new JsonRpcProvider(url, chainId, { + staticNetwork: true, + batchMaxCount: 1, // Disable batching for public RPCs + }) + ); +} + +/** + * Create a FallbackProvider for a given chain + */ +function createFallbackProvider(chainId) { + if (!getChain(chainId)) { + throw new Error(`Unsupported chain ID: ${chainId}`); + } + + const providers = createProviderPool(chainId).map((provider, index) => ({ + provider, priority: index === 0 ? 1 : 2, // First URL has higher priority weight: index === 0 ? 2 : 1, stallTimeout: PROVIDER_CONFIG.stallTimeout, })); - const fallbackProvider = new FallbackProvider(providers, chainId, { + return new FallbackProvider(providers, chainId, { quorum: PROVIDER_CONFIG.quorum, }); +} - return fallbackProvider; +/** + * EIP-1193 view of the chain's RPC pool, for libraries that consume a + * `request` interface (e.g. protocol-kit). FallbackProvider exposes no + * raw send, so this fails over URL-by-URL per request instead. Consumers + * only read through it (eth_call / eth_getCode / eth_chainId) — writes + * stay on the FallbackProvider paths — so retrying on the next URL is + * always safe. + * + * @param {number} chainId + * @returns {{request: (args: {method: string, params?: any[]}) => Promise}} + */ +function getEip1193Provider(chainId) { + if (!eip1193Cache.has(chainId)) { + const providers = createProviderPool(chainId); + eip1193Cache.set(chainId, { + request: async ({ method, params }) => { + let lastError; + for (const provider of providers) { + try { + return await provider.send(method, params ?? []); + } catch (err) { + lastError = err; + } + } + throw lastError; + }, + }); + } + return eip1193Cache.get(chainId); } /** @@ -132,11 +171,14 @@ function getProvider(chainId) { */ function clearProviderCache(chainId) { if (chainId !== undefined) { - providerCache.delete(chainId); - providerCache.delete(String(chainId)); - providerCache.delete(Number(chainId)); + for (const cache of [providerCache, eip1193Cache]) { + cache.delete(chainId); + cache.delete(String(chainId)); + cache.delete(Number(chainId)); + } } else { providerCache.clear(); + eip1193Cache.clear(); } } @@ -147,6 +189,7 @@ function clearProviderCache(chainId) { function onApiKeysChanged() { console.log('[ProviderManager] API keys changed, clearing provider cache'); providerCache.clear(); + eip1193Cache.clear(); registry.invalidate(); } @@ -180,6 +223,7 @@ async function testProvider(chainId) { module.exports = { getProvider, + getEip1193Provider, clearProviderCache, onApiKeysChanged, getAllProviders, diff --git a/src/main/wallet/provider-manager.test.js b/src/main/wallet/provider-manager.test.js new file mode 100644 index 00000000..f65331d8 --- /dev/null +++ b/src/main/wallet/provider-manager.test.js @@ -0,0 +1,99 @@ +const http = require('http'); + +const mockGetEndpoints = jest.fn(); +jest.mock('../networks/network-registry', () => ({ + getEndpoints: (...args) => mockGetEndpoints(...args), + getNetwork: () => null, + getAllNetworks: () => ({}), + invalidate: jest.fn(), +})); + +const { getEip1193Provider, clearProviderCache } = require('./provider-manager'); + +/** Minimal JSON-RPC server; `handler` returns a result or throws. */ +function startRpcServer(handler) { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const { id, method, params } = JSON.parse(body); + try { + const result = handler(method, params); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result })); + } catch (err) { + res.writeHead(500); + res.end(err.message); + } + }); + }); + server.listen(0, '127.0.0.1', () => { + resolve({ server, url: `http://127.0.0.1:${server.address().port}` }); + }); + }); +} + +describe('getEip1193Provider', () => { + const servers = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => new Promise((r) => s.close(r)))); + mockGetEndpoints.mockReset(); + clearProviderCache(); + }); + + test('serves requests from the first healthy endpoint', async () => { + const healthy = await startRpcServer((method) => { + if (method === 'eth_chainId') return '0x64'; + throw new Error('unexpected method'); + }); + servers.push(healthy.server); + mockGetEndpoints.mockReturnValue([healthy.url]); + + const provider = getEip1193Provider(100); + await expect(provider.request({ method: 'eth_chainId' })).resolves.toBe('0x64'); + expect(mockGetEndpoints).toHaveBeenCalledWith(100, 'rpc'); + }); + + test('fails over to the next endpoint when one errors', async () => { + const broken = await startRpcServer(() => { + throw new Error('boom'); + }); + const healthy = await startRpcServer(() => '0x64'); + servers.push(broken.server, healthy.server); + mockGetEndpoints.mockReturnValue([broken.url, healthy.url]); + + const provider = getEip1193Provider(100); + await expect(provider.request({ method: 'eth_chainId' })).resolves.toBe('0x64'); + }); + + test('surfaces the last error when every endpoint fails', async () => { + const broken = await startRpcServer(() => { + throw new Error('boom'); + }); + servers.push(broken.server); + mockGetEndpoints.mockReturnValue([broken.url]); + + const provider = getEip1193Provider(100); + await expect(provider.request({ method: 'eth_chainId' })).rejects.toThrow(); + }); + + test('is cached per chain and invalidated with the provider cache', async () => { + const healthy = await startRpcServer(() => '0x64'); + servers.push(healthy.server); + mockGetEndpoints.mockReturnValue([healthy.url]); + + expect(getEip1193Provider(100)).toBe(getEip1193Provider(100)); + expect(mockGetEndpoints).toHaveBeenCalledTimes(1); + + clearProviderCache(100); + getEip1193Provider(100); + expect(mockGetEndpoints).toHaveBeenCalledTimes(2); + }); + + test('throws when the chain has no configured endpoints', () => { + mockGetEndpoints.mockReturnValue([]); + expect(() => getEip1193Provider(999)).toThrow(/No RPC endpoints/); + }); +}); diff --git a/src/main/wallet/safe/__tests__/helpers/stub-chain.js b/src/main/wallet/safe/__tests__/helpers/stub-chain.js new file mode 100644 index 00000000..1934abb2 --- /dev/null +++ b/src/main/wallet/safe/__tests__/helpers/stub-chain.js @@ -0,0 +1,105 @@ +/** + * Offline EIP-1193 stub chain for SafeExecutor unit tests. + * + * Answers exactly the RPC surface protocol-kit touches for counterfactual + * work: the canonical Safe 1.4.1 contracts read as deployed, everything + * else as empty, and `proxyCreationCode()` replays the on-chain constant + * (recorded once from the real SafeProxyFactory 1.4.1 on Gnosis at + * 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67 — it is identical on every + * chain, that's the point of the canonical deployment). Optional + * `deployedSafes` simulate already-deployed Safes with a live nonce. + * + * Unstubbed methods/calls throw so a protocol-kit upgrade that starts + * reading something new fails loudly instead of silently mis-testing. + */ + +const { Interface, getAddress } = require('ethers'); +const deployments = require('@safe-global/safe-deployments'); + +const SAFE_VERSION = '1.4.1'; + +// eth_call response of SafeProxyFactory.proxyCreationCode() (ABI-encoded +// bytes), recorded from Gnosis. Deterministic protocol constant for 1.4.1. +const PROXY_CREATION_CODE_RESPONSE = + '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001e6608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea264697066735822122003d1488ee65e08fa41e58e888a9865554c535f2c77126a82cb4c0f917f31441364736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f76696465640000000000000000000000000000000000000000000000000000'; + +const SAFE_INTERFACE = new Interface([ + 'function proxyCreationCode() view returns (bytes)', + 'function nonce() view returns (uint256)', + 'function VERSION() view returns (string)', + 'function getThreshold() view returns (uint256)', + 'function getOwners() view returns (address[])', +]); + +/** Canonical 1.4.1 contract addresses from the safe-deployments registry. */ +function canonicalAddresses() { + return [ + deployments.getSafeSingletonDeployment({ version: SAFE_VERSION }), + deployments.getSafeL2SingletonDeployment({ version: SAFE_VERSION }), + deployments.getProxyFactoryDeployment({ version: SAFE_VERSION }), + deployments.getMultiSendDeployment({ version: SAFE_VERSION }), + deployments.getMultiSendCallOnlyDeployment({ version: SAFE_VERSION }), + deployments.getFallbackHandlerDeployment({ version: SAFE_VERSION }), + ] + .filter(Boolean) + .map((d) => getAddress(d.defaultAddress)); +} + +/** + * @param {Object} opts + * @param {number} opts.chainId + * @param {Object.} + * [opts.deployedSafes] - Safes that read as deployed, keyed by address + * @returns {{request: Function}} EIP-1193 provider + */ +function createStubChain({ chainId, deployedSafes = {} }) { + const codeAddresses = new Set(canonicalAddresses().map((a) => a.toLowerCase())); + const safes = Object.fromEntries( + Object.entries(deployedSafes).map(([address, safe]) => [address.toLowerCase(), safe]) + ); + + const encodeResult = (name, values) => SAFE_INTERFACE.encodeFunctionResult(name, values); + + const call = ({ to, data }) => { + const selector = (data || '').slice(0, 10); + const safe = safes[(to || '').toLowerCase()]; + switch (selector) { + case SAFE_INTERFACE.getFunction('proxyCreationCode').selector: + return PROXY_CREATION_CODE_RESPONSE; + case SAFE_INTERFACE.getFunction('nonce').selector: + return encodeResult('nonce', [safe?.nonce ?? 0]); + case SAFE_INTERFACE.getFunction('VERSION').selector: + return encodeResult('VERSION', [SAFE_VERSION]); + case SAFE_INTERFACE.getFunction('getThreshold').selector: + return encodeResult('getThreshold', [safe?.threshold ?? 1]); + case SAFE_INTERFACE.getFunction('getOwners').selector: + return encodeResult('getOwners', [safe?.owners ?? []]); + default: + throw new Error(`stub-chain: unstubbed eth_call ${selector} to ${to}`); + } + }; + + return { + request: async ({ method, params }) => { + switch (method) { + case 'eth_chainId': + return '0x' + chainId.toString(16); + case 'eth_accounts': + return []; + case 'eth_getCode': { + const address = params[0].toLowerCase(); + const deployed = codeAddresses.has(address) || address in safes; + return deployed ? '0x60806040' : '0x'; + } + case 'eth_call': + return call(params[0]); + case 'eth_blockNumber': + return '0x1'; + default: + throw new Error(`stub-chain: unstubbed method ${method}`); + } + }, + }; +} + +module.exports = { createStubChain, canonicalAddresses }; diff --git a/src/main/wallet/safe/__tests__/helpers/test-owners.js b/src/main/wallet/safe/__tests__/helpers/test-owners.js new file mode 100644 index 00000000..1747dc4b --- /dev/null +++ b/src/main/wallet/safe/__tests__/helpers/test-owners.js @@ -0,0 +1,36 @@ +/** + * Shared Safe test identities: three anvil/hardhat-default keys (well + * known, never funded on mainnet) standing in for a mnemonic, a Ledger + * and a phone owner record, plus the signer fake both test suites hand + * out from their `getSigner` mocks. Kept requireable from inside + * jest.mock factories (no jest globals in here). + */ + +const { Wallet } = require('ethers'); +const { withoutDomainType } = require('../../../signing-utils'); + +// wallet index → private key; indexes mirror the mocked wallet records +// (0 mnemonic, 2 ledger, 4 remote). +const OWNER_KEYS = { + 0: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + 2: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + 4: '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a', +}; + +const ownerWallet = (walletIndex) => new Wallet(OWNER_KEYS[walletIndex]); + +/** + * Signer fake mirroring the factory contract: full EIP-712 wire payload + * in, signature out; signTransaction included for executor broadcasts. + */ +function createTestSigner(walletIndex) { + const wallet = ownerWallet(walletIndex); + return { + getAddress: async () => wallet.address, + signTransaction: (tx) => wallet.signTransaction(tx), + signTypedData: async ({ domain, types, message }) => + wallet.signTypedData(domain, withoutDomainType(types), message), + }; +} + +module.exports = { OWNER_KEYS, ownerWallet, createTestSigner }; diff --git a/src/main/wallet/safe/__tests__/integration/safe-fork.test.js b/src/main/wallet/safe/__tests__/integration/safe-fork.test.js new file mode 100644 index 00000000..be3ed6eb --- /dev/null +++ b/src/main/wallet/safe/__tests__/integration/safe-fork.test.js @@ -0,0 +1,405 @@ +/** + * Fork-chain verification of the Safe reproducible-address story + * (research doc Part B, decision 5): the SAME record init params must + * produce the SAME address on every chain, and retroactive deployment + * must claim funds that were sent to the address before it existed. + * + * Runs the real SafeExecutor against anvil forks of live Gnosis and Base + * — canonical safe-deployments factories only, real protocol-kit + * encoding, the real transaction-service broadcast path. Only the signer + * seam is faked (well-known anvil test keys stand in for vault/Ledger/ + * phone owners; they all sign the same EIP-712 payload). + * + * Needs `anvil` (foundry) on PATH and network access to the public fork + * RPCs; skips cleanly otherwise (CI has neither). + */ + +const { spawn, spawnSync } = require('child_process'); +const { + Wallet, + JsonRpcProvider, + parseEther, + hashMessage, + Interface, + TypedDataEncoder, +} = require('ethers'); + +const { ownerWallet } = require('../helpers/test-owners'); + +const FORKS = { + gnosis: { chainId: 100, forkUrl: 'https://rpc.gnosischain.com', port: 18845 }, + base: { chainId: 8453, forkUrl: 'https://mainnet.base.org', port: 18846 }, +}; + +const anvilUrl = (fork) => `http://127.0.0.1:${fork.port}`; + +// Owner signers over the shared test keys, one per fake wallet index. +jest.mock('../../../signers', () => ({ + getSigner: (walletIndex) => require('../helpers/test-owners').createTestSigner(walletIndex), +})); + +// Wallet records for the orchestrator path (owners stored as INDEXES, +// like real vault-meta records). The safe record is added by the test +// once the predicted address is known. +const mockWalletRecords = { + 0: { index: 0, name: 'Main Wallet', type: 'mnemonic', address: ownerWallet(0).address }, + 2: { index: 2, name: 'My Stax', type: 'ledger', address: ownerWallet(2).address }, + 4: { index: 4, name: 'My Phone', type: 'remote', address: ownerWallet(4).address }, +}; +jest.mock('../../../../identity-manager', () => ({ + getWalletRecord: (index) => mockWalletRecords[index] || null, + isVaultUnlocked: async () => true, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', SAFE: 'safe' }, +})); + +// Recording is unit-tested elsewhere; here the broadcast must be real. +jest.mock('../../../tx-recorder', () => ({ + KINDS: { SAFE_SEND: 'safe-send', SAFE_DEPLOY: 'safe-deploy' }, + signAndRecord: (params, signer) => + jest.requireActual('../../../transaction-service').signAndSendTransaction(params, signer), +})); + +// Point the broadcast path (transaction-service → provider-manager) at +// the anvil forks instead of the registry's live RPC pool. +jest.mock('../../../provider-manager', () => { + const { JsonRpcProvider: Provider } = require('ethers'); + const urls = { 100: 'http://127.0.0.1:18845', 8453: 'http://127.0.0.1:18846' }; + const cache = new Map(); + return { + getProvider: (chainId) => { + if (!cache.has(chainId)) { + cache.set(chainId, new Provider(urls[chainId], chainId, { staticNetwork: true })); + } + return cache.get(chainId); + }, + getEip1193Provider: (chainId) => ({ + request: ({ method, params }) => { + if (!cache.has(chainId)) { + cache.set(chainId, new Provider(urls[chainId], chainId, { staticNetwork: true })); + } + return cache.get(chainId).send(method, params ?? []); + }, + }), + withRetry: (fn) => fn(), + }; +}); + +const { + predictSafeAddress, + buildSafeTransaction, + collectOwnerSignature, + execTransaction, + deploySafe, +} = require('../../safe-executor'); + +// --------------------------------------------------------------------------- +// Availability gate (sync, decides skip at collection time) +// --------------------------------------------------------------------------- + +function availability() { + if (spawnSync('anvil', ['--version']).status !== 0) { + return { ok: false, reason: 'anvil (foundry) not on PATH' }; + } + for (const fork of Object.values(FORKS)) { + const probe = spawnSync('curl', [ + '-sf', + '-m', + '10', + '-X', + 'POST', + fork.forkUrl, + '-H', + 'Content-Type: application/json', + '-d', + '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}', + ]); + if (probe.status !== 0) { + return { ok: false, reason: `fork RPC ${fork.forkUrl} unreachable` }; + } + } + return { ok: true }; +} + +const gate = availability(); +if (!gate.ok) { + console.warn(`[safe-fork.test] skipping fork verification: ${gate.reason}`); +} +const describeFork = gate.ok ? describe : describe.skip; + +// --------------------------------------------------------------------------- + +jest.setTimeout(300000); + +async function rpc(url, method, params = []) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const { result, error } = await res.json(); + if (error) throw new Error(`${method}: ${error.message}`); + return result; +} + +// State reads and receipt waits go over raw JSON-RPC: a long-lived ethers +// provider instance caches account state keyed to a block number that +// never advances without polling/subscriptions, and its waitForTransaction +// only re-checks receipts on new blocks — which an automine chain never +// produces after the tx itself. +async function waitForReceipt(fork, hash, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const receipt = await rpc(anvilUrl(fork), 'eth_getTransactionReceipt', [hash]); + if (receipt) return receipt; + if (Date.now() > deadline) throw new Error(`no receipt for ${hash} after ${timeoutMs}ms`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } +} + +const getCode = (fork, address) => rpc(anvilUrl(fork), 'eth_getCode', [address, 'latest']); +const getBalance = async (fork, address) => + BigInt(await rpc(anvilUrl(fork), 'eth_getBalance', [address, 'latest'])); + +async function startAnvil(fork) { + const proc = spawn('anvil', [ + '--fork-url', + fork.forkUrl, + '--port', + String(fork.port), + '--silent', + ]); + const deadline = Date.now() + 120000; + for (;;) { + if (proc.exitCode !== null) throw new Error(`anvil exited with ${proc.exitCode}`); + try { + const chainIdHex = await rpc(anvilUrl(fork), 'eth_chainId'); + if (parseInt(chainIdHex, 16) === fork.chainId) return proc; + throw new Error(`fork reports unexpected chain id ${chainIdHex}`); + } catch (err) { + if (Date.now() > deadline) { + throw new Error(`anvil fork of ${fork.forkUrl} not ready: ${err.message}`, { cause: err }); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } +} + +describeFork('Safe on forked Gnosis + Base (reproducible addresses, retroactive deploy)', () => { + const INIT = { + owners: [ownerWallet(0).address, ownerWallet(2).address, ownerWallet(4).address], + threshold: 2, + saltNonce: '20260709', + }; + const executorAddress = ownerWallet(0).address; + + const procs = []; + let predicted; + + beforeAll(async () => { + const [gnosisProc, baseProc] = await Promise.all([ + startAnvil(FORKS.gnosis), + startAnvil(FORKS.base), + ]); + procs.push(gnosisProc, baseProc); + + // The executor EOA pays gas on both forks. + const balance = '0x' + parseEther('10').toString(16); + await rpc(anvilUrl(FORKS.gnosis), 'anvil_setBalance', [executorAddress, balance]); + await rpc(anvilUrl(FORKS.base), 'anvil_setBalance', [executorAddress, balance]); + }); + + afterAll(() => { + for (const proc of procs) proc.kill(); + }); + + test('the same init params predict the same address on both chains', async () => { + const onGnosis = await predictSafeAddress({ + ...INIT, + chainId: 100, + provider: anvilUrl(FORKS.gnosis), + }); + const onBase = await predictSafeAddress({ + ...INIT, + chainId: 8453, + provider: anvilUrl(FORKS.base), + }); + expect(onBase).toBe(onGnosis); + expect(await getCode(FORKS.base, onBase)).toBe('0x'); + predicted = onGnosis; + }); + + test('retroactive deployment on Base claims funds sent to the empty address', async () => { + // Someone pays the Safe before it exists on this chain… + const funderProvider = new JsonRpcProvider(anvilUrl(FORKS.base), 8453, { staticNetwork: true }); + const funder = ownerWallet(0).connect(funderProvider); + const funding = await funder.sendTransaction({ to: predicted, value: parseEther('1') }); + await waitForReceipt(FORKS.base, funding.hash); + funderProvider.destroy(); + expect(await getBalance(FORKS.base, predicted)).toBe(parseEther('1')); + expect(await getCode(FORKS.base, predicted)).toBe('0x'); + + // …then the ORIGINAL init params deploy the Safe at that address. + const { safeAddress, tx } = await deploySafe({ + ...INIT, + chainId: 8453, + executorIndex: 0, + provider: anvilUrl(FORKS.base), + }); + expect(safeAddress).toBe(predicted); + const receipt = await waitForReceipt(FORKS.base, tx.hash); + expect(receipt.status).toBe('0x1'); + expect((await getCode(FORKS.base, predicted)).length).toBeGreaterThan(2); + + // …and a 2/3-signed SafeTx moves the pre-sent funds out. + const recipient = Wallet.createRandom().address; + const built = await buildSafeTransaction({ + chainId: 8453, + safe: { ...INIT, address: predicted }, + tx: { to: recipient, value: parseEther('1').toString(), data: '0x' }, + provider: anvilUrl(FORKS.base), + }); + expect(built.deployed).toBe(true); + expect(built.safeAddress).toBe(predicted); + + const signatures = [ + await collectOwnerSignature({ typedData: built.typedData, ownerIndex: 0 }), + await collectOwnerSignature({ typedData: built.typedData, ownerIndex: 2 }), + ]; + const result = await execTransaction({ + chainId: 8453, + safeAddress: predicted, + safeTxData: built.safeTxData, + signatures, + executorIndex: 0, + }); + const execReceipt = await waitForReceipt(FORKS.base, result.hash); + expect(execReceipt.status).toBe('0x1'); + + expect(await getBalance(FORKS.base, recipient)).toBe(parseEther('1')); + expect(await getBalance(FORKS.base, predicted)).toBe(0n); + }); + + test('deploying on Gnosis lands on the identical address', async () => { + const { safeAddress, tx } = await deploySafe({ + ...INIT, + chainId: 100, + executorIndex: 0, + provider: anvilUrl(FORKS.gnosis), + }); + expect(safeAddress).toBe(predicted); + const receipt = await waitForReceipt(FORKS.gnosis, tx.hash); + expect(receipt.status).toBe('0x1'); + expect((await getCode(FORKS.gnosis, predicted)).length).toBeGreaterThan(2); + }); + + test('the send orchestrator moves funds starting from a wallet RECORD (owners as indexes)', async () => { + // Regression: the record stores owners as wallet indexes; passing + // them unresolved into protocol-kit blew up with `Address "0" is + // invalid`. This walks the REAL start → sign → execute path, un-mocked. + const { startSafeSend, signSafePending, executeSafePending } = require('../../safe-transactions'); + // The mocked electron userData dir is shared across runs — drop any + // pending entry a previous run left behind. + require('../../pending-store').clearPending(5); + + // Fund the (deployed, previous test) safe on the Gnosis fork. + const gnosisProvider = new JsonRpcProvider(anvilUrl(FORKS.gnosis), 100, { staticNetwork: true }); + const funder = ownerWallet(0).connect(gnosisProvider); + const funding = await funder.sendTransaction({ to: predicted, value: parseEther('1') }); + await waitForReceipt(FORKS.gnosis, funding.hash); + gnosisProvider.destroy(); + + mockWalletRecords[5] = { + index: 5, + name: 'Joint', + type: 'safe', + address: predicted, + owners: [0, 2, 4], // wallet indexes, exactly like vault-meta + threshold: INIT.threshold, + saltNonce: INIT.saltNonce, + deployed: { 100: true }, + }; + + const recipient = Wallet.createRandom().address; + const amount = parseEther('1').toString(); + // start silently signs the mnemonic owner (1 of 2)… + const started = await startSafeSend({ + safeIndex: 5, + tx: { to: recipient, value: amount, data: '0x' }, + display: { toAddress: recipient, asset: null, amount }, + }); + expect(started).toMatchObject({ collected: 1, threshold: 2, status: 'awaiting' }); + + // …the user signs the second owner, then execution runs on its own. + const signed = await signSafePending(5, 2); + expect(signed.collected).toBe(2); + const result = await executeSafePending(5); + expect(result.status).toBe('executed'); + + const receipt = await waitForReceipt(FORKS.gnosis, result.executed.hash); + expect(receipt.status).toBe('0x1'); + expect(await getBalance(FORKS.gnosis, recipient)).toBe(parseEther('1')); + }); + + test('a collected SafeMessage signature passes isValidSignature on chain (EIP-1271)', async () => { + // What WP-S4 promises dApps: personal_sign / typed data answered by + // a Safe account verifies through the REAL contract's fallback + // handler, exactly the call a dApp makes. + const { startSafeMessage, signSafeMessage, completeSafeMessage } = require('../../safe-messages'); + const IS_VALID = new Interface([ + 'function isValidSignature(bytes32 dataHash, bytes signature) view returns (bytes4)', + ]); + const MAGIC = '0x1626ba7e'; + const callIsValid = async (digest, signature) => { + const data = IS_VALID.encodeFunctionData('isValidSignature', [digest, signature]); + const result = await rpc(anvilUrl(FORKS.gnosis), 'eth_call', [ + { to: predicted, data }, + 'latest', + ]); + return result.slice(0, 10); + }; + + // personal_sign, hex-encoded the way dApps send it (2 of 3 owners) + const text = 'freedom 1271'; + const hexMessage = '0x' + Buffer.from(text, 'utf8').toString('hex'); + const started = await startSafeMessage({ + safeIndex: 5, + request: { method: 'personal_sign', params: [hexMessage, predicted] }, + display: { site: 'app.example', method: 'personal_sign' }, + requester: { origin: 'app.example', webContentsId: 1 }, + }); + expect(started).toMatchObject({ collected: 1, threshold: 2, complete: false }); + await signSafeMessage(5, 2, started.token); + const { signature } = completeSafeMessage(5, started.token); + + // the digest a verifying dApp computes: EIP-191 over the BYTES + expect(await callIsValid(hashMessage(text), signature)).toBe(MAGIC); + // …and the same signature over a different digest is refused + await expect(callIsValid(hashMessage('tampered'), signature)).rejects.toThrow(); + + // eth_signTypedData_v4 (JSON-string param, EIP712Domain included) + const typed = { + domain: { name: 'Fork Dapp', chainId: 100, verifyingContract: ownerWallet(0).address }, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Ping: [{ name: 'note', type: 'string' }], + }, + primaryType: 'Ping', + message: { note: 'gm' }, + }; + const startedTyped = await startSafeMessage({ + safeIndex: 5, + request: { method: 'eth_signTypedData_v4', params: [predicted, JSON.stringify(typed)] }, + display: { site: 'app.example', method: 'eth_signTypedData_v4' }, + requester: { origin: 'app.example', webContentsId: 1 }, + }); + await signSafeMessage(5, 4, startedTyped.token); // a different second owner this time + const { signature: typedSignature } = completeSafeMessage(5, startedTyped.token); + + const typedDigest = TypedDataEncoder.hash(typed.domain, { Ping: typed.types.Ping }, typed.message); + expect(await callIsValid(typedDigest, typedSignature)).toBe(MAGIC); + }); +}); diff --git a/src/main/wallet/safe/errors.js b/src/main/wallet/safe/errors.js new file mode 100644 index 00000000..cfc4b23e --- /dev/null +++ b/src/main/wallet/safe/errors.js @@ -0,0 +1,29 @@ +/** + * Safe error codes — the machine-readable side of the Safe flows' error + * contract. Codes cross the IPC boundary (wallet-ipc's safeStateHandler + * forwards `err.code`) and the renderer switches on them to render + * blocking states, unlock walks, and row notes instead of raw text. + * (VAULT_LOCKED is not here: it belongs to the vault layer and is tagged + * by wallet-ipc via isVaultLockedError.) + */ + +const CODES = { + /** Another step (signature ceremony, execution) is live for this Safe. */ + SAFE_BUSY: 'SAFE_BUSY', + /** One pending SafeTx per Safe — finish or discard the current one. */ + SAFE_PENDING_EXISTS: 'SAFE_PENDING_EXISTS', + /** A live SafeMessage session belongs to another page — no takeover. */ + SAFE_MESSAGE_EXISTS: 'SAFE_MESSAGE_EXISTS', + /** The pending item was discarded while a signature was being made. */ + SAFE_DISCARDED: 'SAFE_DISCARDED', + /** Nothing local can pay gas — render the fund-the-executor state. */ + SAFE_NEEDS_FUNDS: 'SAFE_NEEDS_FUNDS', +}; + +function codedError(message, code) { + const err = new Error(message); + err.code = code; + return err; +} + +module.exports = { ...CODES, codedError }; diff --git a/src/main/wallet/safe/message-sessions.js b/src/main/wallet/safe/message-sessions.js new file mode 100644 index 00000000..fcb63f71 --- /dev/null +++ b/src/main/wallet/safe/message-sessions.js @@ -0,0 +1,44 @@ +/** + * In-memory SafeMessage session slots, one per Safe wallet index. + * + * Split out of safe-messages.js so lifecycle owners with no business in + * the signing stack (identity-manager deleting a Safe, webContents + * teardown hooks) can force-drop a session without pulling in ethers/ + * protocol-kit. Entries are opaque here except for `detach`, the hook a + * session registers to unhook its webContents lifecycle listeners. + */ + +const sessions = new Map(); + +/** @returns {Object|null} the live session entry for a safe index */ +function getSession(safeIndex) { + return sessions.get(safeIndex) || null; +} + +function setSession(safeIndex, entry) { + sessions.set(safeIndex, entry); +} + +/** + * Force-drop a session regardless of its request token — the trusted + * cleanup path (Safe deletion, requesting page gone, test reset). A + * signature ceremony that lands afterwards is dropped by the identity + * re-check in signature-collection, never attached to a new session. + * + * @returns {boolean} whether a session existed + */ +function discardSession(safeIndex) { + const entry = sessions.get(safeIndex); + if (!entry) { + return false; + } + sessions.delete(safeIndex); + try { + entry.detach?.(); + } catch { + // webContents already torn down — nothing left to unhook + } + return true; +} + +module.exports = { getSession, setSession, discardSession }; diff --git a/src/main/wallet/safe/pending-store.js b/src/main/wallet/safe/pending-store.js new file mode 100644 index 00000000..bedbe6d9 --- /dev/null +++ b/src/main/wallet/safe/pending-store.js @@ -0,0 +1,70 @@ +/** + * Pending SafeTx store — the half-signed transaction that survives a + * restart, as interim JSON (same posture as the other wallet JSON + * stores; migrates into the unified wallet-history SQLite later). + * + * One pending SafeTx per Safe at a time, by design (research doc B.4): + * a single slot sidesteps the nonce-replacement/ordering swamp that + * Safe's hosted service exists to manage. Collected signatures are just + * bytes — safe to persist, useless without the Safe and its chain state. + */ + +const { app } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +const PENDING_FILE = 'safe-pending.json'; + +let cache = null; + +function getFilePath() { + return path.join(app.getPath('userData'), PENDING_FILE); +} + +function load() { + if (cache !== null) { + return cache; + } + try { + cache = fs.existsSync(getFilePath()) + ? JSON.parse(fs.readFileSync(getFilePath(), 'utf-8')) + : {}; + } catch (err) { + console.error('[SafePending] Failed to load pending store:', err); + cache = {}; + } + return cache; +} + +function save() { + try { + fs.writeFileSync(getFilePath(), JSON.stringify(cache, null, 2), 'utf-8'); + } catch (err) { + console.error('[SafePending] Failed to save pending store:', err); + } +} + +/** @returns {Object|null} The pending entry for a safe wallet index */ +function getPending(safeIndex) { + return load()[safeIndex] || null; +} + +function setPending(safeIndex, entry) { + load()[safeIndex] = entry; + save(); +} + +function clearPending(safeIndex) { + const store = load(); + if (safeIndex in store) { + delete store[safeIndex]; + save(); + } +} + +/** Safe wallet indexes that currently have a pending SafeTx. */ +function listPendingIndexes() { + return Object.keys(load()).map(Number); +} + +module.exports = { getPending, setPending, clearPending, listPendingIndexes }; diff --git a/src/main/wallet/safe/safe-executor.js b/src/main/wallet/safe/safe-executor.js new file mode 100644 index 00000000..0d3f5b3e --- /dev/null +++ b/src/main/wallet/safe/safe-executor.js @@ -0,0 +1,321 @@ +/** + * SafeExecutor — execution layer for Safe smart accounts. + * + * A Safe account has no key of its own: its owners are other wallet + * records, and a SafeTx is EIP-712 typed data, so every signer backend + * (vault, Ledger, phone) co-signs through the ordinary `getSigner` seam. + * This module orchestrates the rest: predict the counterfactual address, + * build the deployment, build/hash a SafeTx, collect owner signatures, + * and submit `execTransaction` through an executor EOA that pays the gas. + * + * Everything runs against the user's own RPC pool via protocol-kit — + * never Safe's hosted Transaction Service. All returned shapes are plain + * JSON (IPC- and persistence-friendly); protocol-kit objects stay + * internal. + * + * Address reproducibility invariant: a Safe record's ORIGINAL init params + * (owners, threshold, saltNonce) must never change — they are what makes + * the CREATE2 address recomputable for retroactive deployment on other + * chains. buildSafeTransaction re-derives the address from them and + * refuses to proceed on a mismatch. + */ + +const { getAddress, verifyTypedData, Interface, TypedDataEncoder } = require('ethers'); +const Safe = require('@safe-global/protocol-kit').default; +const { + generateTypedData, + EthSafeTransaction, + EthSafeSignature, +} = require('@safe-global/protocol-kit'); +const { getProxyFactoryDeployment } = require('@safe-global/safe-deployments'); + +const { getSigner } = require('../signers'); +const { withoutDomainType } = require('../signing-utils'); +const { + estimateGas, + getGasPrices, + toFeeFields, + signAndSendTransaction, +} = require('../transaction-service'); +const { signAndRecord } = require('../tx-recorder'); +const { getWalletRecord, WALLET_TYPES } = require('../../identity-manager'); +const { getEip1193Provider } = require('../provider-manager'); + +/** Contract version every freedom Safe is created with. */ +const SAFE_VERSION = '1.4.1'; + +const EXEC_INTERFACE = new Interface([ + 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)', +]); + +const resolveProvider = (provider, chainId) => provider ?? getEip1193Provider(chainId); + +/** protocol-kit instance for a not-yet-assumed-deployed safe. */ +function initPredictedKit({ owners, threshold, saltNonce, chainId, provider }) { + // This layer works in owner ADDRESSES; wallet records store owners as + // wallet INDEXES — fail loudly on the mixup instead of letting the + // address parser produce a cryptic error deep inside protocol-kit. + if (!Array.isArray(owners) || owners.some((owner) => typeof owner !== 'string')) { + throw new Error('Safe owners must be addresses here — resolve wallet indexes first'); + } + return Safe.init({ + provider: resolveProvider(provider, chainId), + predictedSafe: { + safeAccountConfig: { owners, threshold }, + safeDeploymentConfig: { saltNonce: String(saltNonce), safeVersion: SAFE_VERSION }, + }, + }); +} + +/** + * Deployments must go through the canonical factory from the + * safe-deployments registry — never a user- or dApp-supplied one. The + * canonical 1.4.1 factory has the same address on every chain, which is + * also what makes counterfactual addresses portable. + */ +function assertCanonicalFactory(factoryAddress) { + const canonical = getAddress( + getProxyFactoryDeployment({ version: SAFE_VERSION }).defaultAddress + ); + if (getAddress(factoryAddress) !== canonical) { + throw new Error( + `Refusing non-canonical Safe factory ${factoryAddress} (expected ${canonical})` + ); + } +} + +/** Recursively replace BigInts so results survive JSON / IPC boundaries. */ +function toJsonSafe(value) { + return JSON.parse( + JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? Number(v) : v)) + ); +} + +/** + * Counterfactual (CREATE2) address for the given init params — the same + * on every chain, deployed or not. + * + * @param {Object} params + * @param {string[]} params.owners - Owner addresses + * @param {number} params.threshold + * @param {string|number} params.saltNonce + * @param {number} params.chainId + * @param {Object|string} [params.provider] - EIP-1193 or RPC URL override + * @returns {Promise} Checksummed address + */ +async function predictSafeAddress({ owners, threshold, saltNonce, chainId, provider }) { + const kit = await initPredictedKit({ owners, threshold, saltNonce, chainId, provider }); + return getAddress(await kit.getAddress()); +} + +/** + * The raw deployment transaction (to = canonical factory) plus the + * address it will land on, for callers that want to inspect or quote it + * before sending. + * + * @returns {Promise<{safeAddress: string, to: string, value: string, data: string}>} + */ +async function buildDeploymentTransaction({ owners, threshold, saltNonce, chainId, provider }) { + const kit = await initPredictedKit({ owners, threshold, saltNonce, chainId, provider }); + const safeAddress = getAddress(await kit.getAddress()); + const { to, value, data } = await kit.createSafeDeploymentTransaction(); + assertCanonicalFactory(to); + return { safeAddress, to: getAddress(to), value: String(value), data }; +} + +/** + * Build and hash a SafeTx for a (deployed or counterfactual) Safe. + * + * The nonce comes from the chain when the Safe is deployed, else 0. The + * returned `typedData` is the exact EIP-712 payload every owner signs + * (full wire shape, EIP712Domain included) and `safeTxHash` its digest. + * + * @param {Object} params + * @param {number} params.chainId + * @param {Object} params.safe - Safe record: original init params + * (owners, threshold, saltNonce) and optionally the stored address, + * which is verified against the re-derived one. + * @param {Object} params.tx - {to, value, data?} — plain CALL only + * @param {Object|string} [params.provider] + * @returns {Promise<{safeAddress: string, deployed: boolean, + * safeTxData: Object, safeTxHash: string, typedData: Object}>} + */ +async function buildSafeTransaction({ chainId, safe, tx, provider }) { + const resolved = resolveProvider(provider, chainId); + let kit = await initPredictedKit({ ...safe, chainId, provider: resolved }); + const safeAddress = getAddress(await kit.getAddress()); + + // Guard the reproducibility invariant: if the record's stored address + // no longer matches its init params, something mutated what must be + // frozen — refuse rather than sign for the wrong account. + if (safe.address && getAddress(safe.address) !== safeAddress) { + throw new Error( + `Safe address mismatch: record says ${safe.address}, init params derive ${safeAddress}` + ); + } + + const deployed = await kit.isSafeDeployed(); + if (deployed) { + kit = await kit.connect({ provider: resolved, safeAddress }); + } + + const safeTx = await kit.createTransaction({ + transactions: [{ to: getAddress(tx.to), value: String(tx.value ?? '0'), data: tx.data || '0x' }], + }); + const typedData = toJsonSafe( + generateTypedData({ + safeAddress, + safeVersion: SAFE_VERSION, + chainId: BigInt(chainId), + data: safeTx.data, + }) + ); + const safeTxHash = TypedDataEncoder.hash( + typedData.domain, + withoutDomainType(typedData.types), + typedData.message + ); + + return { safeAddress, deployed, safeTxData: toJsonSafe(safeTx.data), safeTxHash, typedData }; +} + +/** + * One owner's signature over a SafeTx typed-data payload, through its + * normal signer backend (vault instantly, Ledger tap, phone QR) — + * recover-verified before it counts, so a mis-keyed or compromised + * device fails here rather than on-chain. + * + * @param {Object} params + * @param {Object} params.typedData - From buildSafeTransaction + * @param {number} params.ownerIndex - Wallet index of the owner + * @returns {Promise<{signer: string, data: string}>} + */ +async function collectOwnerSignature({ typedData, ownerIndex }) { + const signer = getSigner(ownerIndex); + const address = await signer.getAddress(); + const data = await signer.signTypedData(typedData); + const recovered = verifyTypedData( + typedData.domain, + withoutDomainType(typedData.types), + typedData.message, + data + ); + if (recovered !== getAddress(address)) { + throw new Error(`Owner signature from ${address} does not match: recovered ${recovered}`); + } + return { signer: getAddress(address), data }; +} + +/** + * Quote gas against the executor account and broadcast through the + * ordinary transaction-service path (the executor EOA pays the fee). + * + * With a `record` context the tx lands in payment history (the executor + * address is stamped into the metadata automatically); without one the + * broadcast is bare — callers own the recording decision. + */ +async function sendViaExecutor({ chainId, to, data, executorIndex, record }) { + const executor = getSigner(executorIndex); + const from = await executor.getAddress(); + const [{ gasLimit }, gasPrices] = await Promise.all([ + estimateGas({ from, to, value: '0', data, chainId }), + getGasPrices(chainId), + ]); + const params = { to, value: '0', data, gasLimit, ...toFeeFields(gasPrices), chainId }; + if (record) { + return signAndRecord(params, executor, { + ...record, + metadata: { ...record.metadata, executor: from }, + }); + } + return signAndSendTransaction(params, executor); +} + +/** + * Submit a fully-signed SafeTx via `execTransaction`. Encoding is fully + * local (protocol-kit's SafeTransaction sorts and concatenates the + * signatures); only gas quoting and the broadcast touch the chain. + * + * @param {Object} params + * @param {number} params.chainId + * @param {string} params.safeAddress - Deployed Safe + * @param {Object} params.safeTxData - From buildSafeTransaction + * @param {Array<{signer: string, data: string}>} params.signatures + * @param {number} params.executorIndex - Wallet index that pays gas + * @param {Object} [params.record] - Payment-history context (tx-recorder + * shape); from should be the SAFE address, the executor is stamped in + * @returns {Promise} transaction-service result ({hash, …}) + */ +async function execTransaction({ chainId, safeAddress, safeTxData, signatures, executorIndex, record }) { + const safeTx = new EthSafeTransaction(safeTxData); + for (const { signer, data } of signatures) { + safeTx.addSignature(new EthSafeSignature(signer, data)); + } + const { to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver } = + safeTx.data; + const calldata = EXEC_INTERFACE.encodeFunctionData('execTransaction', [ + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + safeTx.encodedSignatures(), + ]); + return sendViaExecutor({ + chainId, + to: getAddress(safeAddress), + data: calldata, + executorIndex, + record, + }); +} + +/** + * Deploy the Safe through the canonical factory, paid by the executor. + * Also the retroactive-deployment path: same init params on another + * chain produce the same address, claiming any funds already sent there. + * + * Callers that already built the deployment (e.g. to quote its gas) pass + * it as `deployment` to skip the second protocol-kit build. + * + * @returns {Promise<{safeAddress: string, tx: Object}>} + */ +async function deploySafe({ owners, threshold, saltNonce, chainId, executorIndex, provider, deployment, record }) { + const { safeAddress, to, data } = + deployment ?? + (await buildDeploymentTransaction({ owners, threshold, saltNonce, chainId, provider })); + const tx = await sendViaExecutor({ chainId, to, data, executorIndex, record }); + return { safeAddress, tx }; +} + +/** + * Default executor: the first mnemonic owner — it always exists locally + * and can sign + broadcast without another device round-trip. Whether it + * can actually PAY is a UX-level check (the "needs funds" blocking state). + * + * @param {number[]} ownerIndexes + * @returns {number} Wallet index + */ +function pickDefaultExecutor(ownerIndexes) { + for (const index of ownerIndexes) { + if (getWalletRecord(index)?.type === WALLET_TYPES.MNEMONIC) { + return index; + } + } + throw new Error('No browser account among the owners to act as executor — choose one that can pay gas'); +} + +module.exports = { + SAFE_VERSION, + predictSafeAddress, + buildDeploymentTransaction, + buildSafeTransaction, + collectOwnerSignature, + execTransaction, + deploySafe, + pickDefaultExecutor, +}; diff --git a/src/main/wallet/safe/safe-executor.test.js b/src/main/wallet/safe/safe-executor.test.js new file mode 100644 index 00000000..468e9021 --- /dev/null +++ b/src/main/wallet/safe/safe-executor.test.js @@ -0,0 +1,373 @@ +const { TypedDataEncoder, Interface, verifyTypedData } = require('ethers'); + +const { createStubChain, canonicalAddresses } = require('./__tests__/helpers/stub-chain'); +const { ownerWallet, createTestSigner } = require('./__tests__/helpers/test-owners'); +const { withoutDomainType } = require('../signing-utils'); +const deployments = require('@safe-global/safe-deployments'); +const { calculateSafeTransactionHash } = require('@safe-global/protocol-kit'); + +const OWNERS = [ownerWallet(0).address, ownerWallet(2).address, ownerWallet(4).address]; + +const mockWalletRecords = { + 0: { index: 0, name: 'Main Wallet', address: OWNERS[0], type: 'mnemonic' }, + 2: { index: 2, name: 'My Stax', address: OWNERS[1], type: 'ledger' }, + 4: { index: 4, name: 'My Phone', address: OWNERS[2], type: 'remote' }, +}; + +// Wrapped in jest.fn so tests can count calls and override single owners. +const mockGetSigner = jest.fn(createTestSigner); + +const mockEstimateGas = jest.fn(async () => ({ gasLimit: '150000' })); +const mockGetGasPrices = jest.fn(async () => ({ + type: 'eip1559', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', +})); +const mockSignAndSendTransaction = jest.fn(async () => ({ hash: '0x' + 'aa'.repeat(32) })); + +jest.mock('../signers', () => ({ + getSigner: (...args) => mockGetSigner(...args), +})); +jest.mock('../transaction-service', () => ({ + estimateGas: (...args) => mockEstimateGas(...args), + getGasPrices: (...args) => mockGetGasPrices(...args), + // the pure fee-shape mapper stays real so its behaviour is under test + toFeeFields: jest.requireActual('../transaction-service').toFeeFields, + signAndSendTransaction: (...args) => mockSignAndSendTransaction(...args), +})); +const mockSignAndRecord = jest.fn(async () => ({ hash: '0x' + 'bb'.repeat(32), recorded: true })); +jest.mock('../tx-recorder', () => ({ + signAndRecord: (...args) => mockSignAndRecord(...args), +})); +jest.mock('../../identity-manager', () => ({ + getWalletRecord: (index) => mockWalletRecords[index] || null, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', SAFE: 'safe' }, +})); +jest.mock('../provider-manager', () => ({ + getEip1193Provider: () => { + throw new Error('unit tests must inject a provider'); + }, +})); + +const { + SAFE_VERSION, + predictSafeAddress, + buildDeploymentTransaction, + buildSafeTransaction, + collectOwnerSignature, + execTransaction, + deploySafe, + pickDefaultExecutor, +} = require('./safe-executor'); + +jest.setTimeout(20000); + +const INIT = { owners: OWNERS, threshold: 2, saltNonce: '7508' }; +const gnosis = () => createStubChain({ chainId: 100 }); + +beforeEach(() => { + mockGetSigner.mockClear(); + mockEstimateGas.mockClear(); + mockGetGasPrices.mockClear(); + mockSignAndSendTransaction.mockClear(); + mockSignAndRecord.mockClear(); +}); + +describe('predictSafeAddress', () => { + test('is deterministic and checksummed', async () => { + const a = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + const b = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + expect(a).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(a).toBe(b); + }); + + test('same init params produce the same address on different chains (CREATE2 parity)', async () => { + const onGnosis = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + const onBase = await predictSafeAddress({ + ...INIT, + chainId: 8453, + provider: createStubChain({ chainId: 8453 }), + }); + expect(onBase).toBe(onGnosis); + }); + + test('saltNonce and owner set both change the address', async () => { + const base = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + const otherSalt = await predictSafeAddress({ + ...INIT, + saltNonce: '9999', + chainId: 100, + provider: gnosis(), + }); + const otherOwners = await predictSafeAddress({ + ...INIT, + owners: OWNERS.slice(0, 2), + chainId: 100, + provider: gnosis(), + }); + expect(otherSalt).not.toBe(base); + expect(otherOwners).not.toBe(base); + }); +}); + +describe('buildDeploymentTransaction', () => { + test('targets the canonical SafeProxyFactory from the deployments registry', async () => { + const tx = await buildDeploymentTransaction({ ...INIT, chainId: 100, provider: gnosis() }); + const factory = deployments.getProxyFactoryDeployment({ version: SAFE_VERSION }).defaultAddress; + expect(tx.to).toBe(factory); + expect(canonicalAddresses()).toContain(tx.to); + expect(tx.value).toBe('0'); + expect(tx.data.length).toBeGreaterThan(10); + expect(tx.safeAddress).toBe(await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() })); + }); +}); + +describe('buildSafeTransaction', () => { + const TX = { to: OWNERS[0], value: '1000', data: '0x' }; + + test('counterfactual safe: nonce 0, predicted address, not deployed', async () => { + const built = await buildSafeTransaction({ + chainId: 100, + safe: INIT, + tx: TX, + provider: gnosis(), + }); + const predicted = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + expect(built.safeAddress).toBe(predicted); + expect(built.deployed).toBe(false); + expect(built.safeTxData.nonce).toBe(0); + expect(built.safeTxData.to).toBe(TX.to); + expect(built.safeTxData.value).toBe(TX.value); + expect(built.safeTxData.operation).toBe(0); + }); + + test('deployed safe: nonce comes from the chain', async () => { + const address = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + const provider = createStubChain({ + chainId: 100, + deployedSafes: { [address]: { nonce: 5, threshold: 2, owners: OWNERS } }, + }); + const built = await buildSafeTransaction({ chainId: 100, safe: INIT, tx: TX, provider }); + expect(built.deployed).toBe(true); + expect(built.safeTxData.nonce).toBe(5); + }); + + test('safeTxHash matches protocol-kit and ethers independently (vector check)', async () => { + const built = await buildSafeTransaction({ + chainId: 100, + safe: INIT, + tx: TX, + provider: gnosis(), + }); + const fromProtocolKit = calculateSafeTransactionHash( + built.safeAddress, + built.safeTxData, + SAFE_VERSION, + 100n + ); + const types = withoutDomainType(built.typedData.types); + const fromEthers = TypedDataEncoder.hash(built.typedData.domain, types, built.typedData.message); + expect(built.safeTxHash).toBe(fromProtocolKit); + expect(built.safeTxHash).toBe(fromEthers); + expect(built.typedData.primaryType).toBe('SafeTx'); + }); + + test('the whole result is JSON-serializable (IPC / persistence)', async () => { + const built = await buildSafeTransaction({ + chainId: 100, + safe: INIT, + tx: TX, + provider: gnosis(), + }); + const roundTripped = JSON.parse(JSON.stringify(built)); + expect(roundTripped).toEqual(built); + }); +}); + +describe('collectOwnerSignature', () => { + let typedData; + + beforeAll(async () => { + const built = await buildSafeTransaction({ + chainId: 100, + safe: INIT, + tx: { to: OWNERS[0], value: '1000', data: '0x' }, + provider: gnosis(), + }); + typedData = built.typedData; + }); + + test('returns a recover-verified signature for the owner', async () => { + const signature = await collectOwnerSignature({ typedData, ownerIndex: 2 }); + expect(signature.signer).toBe(OWNERS[1]); + const types = withoutDomainType(typedData.types); + expect(verifyTypedData(typedData.domain, types, typedData.message, signature.data)).toBe( + OWNERS[1] + ); + }); + + test('rejects a signature that does not recover to the owner address', async () => { + // A compromised device returns a signature from the wrong key. + mockGetSigner.mockImplementationOnce(() => ({ + getAddress: async () => OWNERS[0], + signTypedData: async ({ domain, types, message }) => + ownerWallet(2).signTypedData(domain, withoutDomainType(types), message), + })); + await expect(collectOwnerSignature({ typedData, ownerIndex: 0 })).rejects.toThrow( + /signature.*does not match/i + ); + }); + + test('propagates signer errors (device rejection)', async () => { + mockGetSigner.mockImplementationOnce(() => ({ + getAddress: async () => OWNERS[0], + signTypedData: async () => { + throw new Error('Rejected on device'); + }, + })); + await expect(collectOwnerSignature({ typedData, ownerIndex: 0 })).rejects.toThrow( + 'Rejected on device' + ); + }); +}); + +describe('execTransaction', () => { + const EXEC_INTERFACE = new Interface([ + 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)', + ]); + + async function buildSignedTx() { + const address = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + const provider = createStubChain({ + chainId: 100, + deployedSafes: { [address]: { nonce: 0, threshold: 2, owners: OWNERS } }, + }); + const built = await buildSafeTransaction({ + chainId: 100, + safe: INIT, + tx: { to: OWNERS[2], value: '1000', data: '0x' }, + provider, + }); + const signatures = [ + await collectOwnerSignature({ typedData: built.typedData, ownerIndex: 0 }), + await collectOwnerSignature({ typedData: built.typedData, ownerIndex: 2 }), + ]; + return { built, signatures, provider }; + } + + test('submits execTransaction calldata through the executor signer', async () => { + const { built, signatures } = await buildSignedTx(); + const result = await execTransaction({ + chainId: 100, + safeAddress: built.safeAddress, + safeTxData: built.safeTxData, + signatures, + executorIndex: 0, + }); + + expect(result.hash).toBe('0x' + 'aa'.repeat(32)); + expect(mockSignAndSendTransaction).toHaveBeenCalledTimes(1); + const [params, signer] = mockSignAndSendTransaction.mock.calls[0]; + expect(params).toMatchObject({ + to: built.safeAddress, + value: '0', + chainId: 100, + gasLimit: '150000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + }); + await expect(signer.getAddress()).resolves.toBe(OWNERS[0]); + + // calldata is a well-formed execTransaction embedding both signatures + const decoded = EXEC_INTERFACE.decodeFunctionData('execTransaction', params.data); + expect(decoded.to).toBe(OWNERS[2]); + expect(decoded.value).toBe(1000n); + // two 65-byte signatures, sorted and concatenated + expect(decoded.signatures.length).toBe(2 + 65 * 2 * 2); + + // gas was estimated for the executor on the real calldata + expect(mockEstimateGas).toHaveBeenCalledWith({ + from: OWNERS[0], + to: built.safeAddress, + value: '0', + data: params.data, + chainId: 100, + }); + }); + + test('a record context routes through tx-recorder with from = safe and the executor stamped in', async () => { + const { built, signatures } = await buildSignedTx(); + const result = await execTransaction({ + chainId: 100, + safeAddress: built.safeAddress, + safeTxData: built.safeTxData, + signatures, + executorIndex: 0, + record: { + kind: 'safe-send', + fromAddress: built.safeAddress, + toAddress: OWNERS[2], + amount: '1000', + metadata: { safeAddress: built.safeAddress }, + }, + }); + + expect(result.hash).toBe('0x' + 'bb'.repeat(32)); + expect(mockSignAndSendTransaction).not.toHaveBeenCalled(); + const [params, signer, context] = mockSignAndRecord.mock.calls[0]; + expect(params).toMatchObject({ to: built.safeAddress, value: '0', chainId: 100 }); + await expect(signer.getAddress()).resolves.toBe(OWNERS[0]); + expect(context).toEqual({ + kind: 'safe-send', + fromAddress: built.safeAddress, + toAddress: OWNERS[2], + amount: '1000', + metadata: { safeAddress: built.safeAddress, executor: OWNERS[0] }, + }); + }); + + test('falls back to legacy gas pricing when the chain has no EIP-1559 data', async () => { + mockGetGasPrices.mockResolvedValueOnce({ type: 'legacy', gasPrice: '5000000000' }); + const { built, signatures } = await buildSignedTx(); + await execTransaction({ + chainId: 100, + safeAddress: built.safeAddress, + safeTxData: built.safeTxData, + signatures, + executorIndex: 0, + }); + const [params] = mockSignAndSendTransaction.mock.calls[0]; + expect(params.gasPrice).toBe('5000000000'); + expect(params.maxFeePerGas).toBeUndefined(); + }); +}); + +describe('deploySafe', () => { + test('sends the canonical factory deployment through the executor and returns the address', async () => { + const { safeAddress, tx } = await deploySafe({ + ...INIT, + chainId: 100, + executorIndex: 0, + provider: gnosis(), + }); + const predicted = await predictSafeAddress({ ...INIT, chainId: 100, provider: gnosis() }); + expect(safeAddress).toBe(predicted); + expect(tx.hash).toBe('0x' + 'aa'.repeat(32)); + + const [params, signer] = mockSignAndSendTransaction.mock.calls[0]; + const factory = deployments.getProxyFactoryDeployment({ version: SAFE_VERSION }).defaultAddress; + expect(params.to).toBe(factory); + expect(params.value).toBe('0'); + await expect(signer.getAddress()).resolves.toBe(OWNERS[0]); + }); +}); + +describe('pickDefaultExecutor', () => { + test('picks the first mnemonic owner regardless of list order', () => { + expect(pickDefaultExecutor([2, 0, 4])).toBe(0); + }); + + test('throws when no owner can pay gas locally', () => { + expect(() => pickDefaultExecutor([2, 4])).toThrow(/executor/i); + }); +}); diff --git a/src/main/wallet/safe/safe-messages.js b/src/main/wallet/safe/safe-messages.js new file mode 100644 index 00000000..01dc3b4a --- /dev/null +++ b/src/main/wallet/safe/safe-messages.js @@ -0,0 +1,336 @@ +/** + * SafeMessage signing sessions — EIP-1271 message signing for dApps. + * + * A dApp's personal_sign / eth_signTypedData_v4 against a Safe account + * is answered with owner signatures over the SafeMessage EIP-712 + * envelope; the verifying dApp calls `isValidSignature` on the Safe, + * whose fallback handler checks the same owner signatures against the + * same envelope. Owners sign through the ordinary collection machinery + * (signature-collection.js), so the board UX — free vault signatures, + * Ledger tap, phone QR — is identical to sends. + * + * Sessions are IN-MEMORY only, unlike pending sends: a dApp request is + * a live promise that dies with its page (or the app), so a persisted + * half-signed message could never be delivered. They also don't touch + * the Safe nonce, so a parked send never blocks a message session — + * only the per-safe device-ceremony lock is shared. + * + * Every session belongs to exactly one requesting page: it is bound to + * the requester (site + webContents) that opened it and guarded by an + * unguessable per-session token that every subsequent state/sign/ + * complete/cancel call must present. Another page can neither observe, + * resume, nor replace a live session — it is told to wait. Sessions of + * pages that navigated away or closed are dropped automatically. + */ + +const { randomUUID } = require('crypto'); +const { getAddress, hashMessage, TypedDataEncoder } = require('ethers'); +const { getEip712MessageTypes, buildSignatureBytes } = require('@safe-global/protocol-kit'); + +const { SAFE_VERSION } = require('./safe-executor'); +const { getSafeRecord, DEPLOY_CHAIN_ID } = require('./safe-service'); +const { + normalizeMessage, + normalizeTypedData, + withoutDomainType, +} = require('../signing-utils'); +const { + release, + acquire, + isBusy, + ownersView, + collectFreeSignatures, + signEntryOwner, +} = require('./signature-collection'); +const { getSession, setSession, discardSession } = require('./message-sessions'); +const { codedError, SAFE_BUSY, SAFE_MESSAGE_EXISTS } = require('./errors'); + +const store = { get: getSession, set: setSession, idOf: (entry) => entry.hash }; + +/** + * The digest a VERIFIER computes for the dApp's request — EIP-191 for + * personal messages (0x-hex decoded to bytes, exactly like the EOA + * signer backends), the EIP-712 hash for typed data. This digest is + * what goes into the SafeMessage envelope's `message` bytes field. + * (protocol-kit's hashSafeMessage is deliberately not used for personal + * messages: viem hashes hex STRINGS as UTF-8 text.) + */ +function requestDigest(method, params) { + if (method === 'personal_sign') { + return hashMessage(normalizeMessage(params[0])); + } + if (method === 'eth_signTypedData_v4') { + const { domain, types, message } = normalizeTypedData(params[1]); + return TypedDataEncoder.hash(domain, withoutDomainType(types), message); + } + throw new Error(`Unsupported signing method for Safe accounts: ${method}`); +} + +/** + * The session's caller identity: the requesting page's permission key + * plus its webview's webContents id. Two tabs are two callers even on + * the same site — an unknown id never matches anything. + */ +function sameRequester(a, b) { + return Boolean( + a && + b && + a.webContentsId != null && + a.webContentsId === b.webContentsId && + a.origin === b.origin + ); +} + +/** The Electron webContents behind a requester, when still around. */ +function requesterWebContents(requester) { + if (requester?.webContentsId == null) { + return null; + } + try { + return require('electron').webContents?.fromId?.(requester.webContentsId) || null; + } catch { + return null; + } +} + +/** + * A session whose requesting page provably no longer exists is a dead + * leftover (its promise died with the page) — safe to replace. Unknown + * liveness counts as ALIVE: the new request is refused rather than a + * possibly-live ceremony hijacked. + */ +function requesterGone(entry) { + if (entry.requester?.webContentsId == null) { + return false; + } + const contents = requesterWebContents(entry.requester); + return !contents || contents.isDestroyed(); +} + +/** + * Drop the session when its requesting page goes away — closed tab or + * a main-frame navigation (including reload): either way the dApp's + * promise is gone and the collected signatures must not linger for + * whatever loads next. + */ +function attachRequesterLifecycle(safeIndex, entry) { + const contents = requesterWebContents(entry.requester); + if (!contents) { + return; + } + const drop = () => { + if (getSession(safeIndex) === entry) { + discardSession(safeIndex); + } + }; + contents.on('destroyed', drop); + contents.on('did-navigate', drop); + entry.detach = () => { + contents.removeListener('destroyed', drop); + contents.removeListener('did-navigate', drop); + }; +} + +/** The session, but only for the caller holding its token. */ +function requireSession(safeIndex, token) { + const entry = getSession(safeIndex); + if (!entry) { + throw new Error('No signature request is open for this account'); + } + if (token !== entry.token) { + throw new Error('This signature request belongs to a different page'); + } + return entry; +} + +/** The board's render model (internal — no token gate). */ +function sessionState(safeIndex, entry = getSession(safeIndex)) { + if (!entry) { + return null; + } + + let ownerIndexes = []; + try { + ownerIndexes = getSafeRecord(safeIndex).owners; + } catch { + // record gone — render what the session alone supports + } + return { + safeIndex, + kind: 'message', + token: entry.token, + chainId: entry.chainId, + hash: entry.hash, + threshold: entry.threshold, + collected: entry.signatures.length, + owners: ownersView(ownerIndexes, entry.signatures), + display: entry.display, + createdAt: entry.createdAt, + complete: entry.signatures.length >= entry.threshold, + }; +} + +/** + * The render model for the caller holding the session token; null when + * no session is open — or when the token doesn't match, which renders + * exactly like "nothing to show" for that caller. + */ +function getSafeMessageState(safeIndex, token) { + const entry = getSession(safeIndex); + if (!entry || token !== entry.token) { + return null; + } + return sessionState(safeIndex, entry); +} + +/** + * Open a SafeMessage session for a dApp signing request and silently + * collect the free signatures (mnemonic owners, vault unlocked). Never + * touches a device: the signing board drives those, per user action. + * + * The returned state carries the session `token` — the capability for + * every follow-up call. Re-issuing the identical request from the SAME + * page resumes its session (collected signatures stay valid for the + * identical hash); any other page is refused while a session is live. + * + * @param {Object} params + * @param {number} params.safeIndex + * @param {{method: string, params: Array}} params.request - The dApp's + * verbatim personal_sign / eth_signTypedData_v4 request + * @param {Object} params.display - Presentation facts for the board + * (site, method, preview…), stored verbatim + * @param {{origin: string, webContentsId: number|null}} [params.requester] + * - The requesting page's identity (permission key + webview + * webContents id) + * @returns {Promise} SafeMessageState (including `token`) + */ +async function startSafeMessage({ safeIndex, request, display, requester }) { + const record = getSafeRecord(safeIndex); + // isValidSignature lives on the deployed contract — nothing to verify + // against before activation. + if (!record.deployed?.[DEPLOY_CHAIN_ID]) { + throw new Error('Activate this account on Gnosis before signing for apps'); + } + const digest = requestDigest(request.method, request.params); + const safeAddress = getAddress(record.address); + const typedData = { + types: getEip712MessageTypes(SAFE_VERSION), + domain: { chainId: DEPLOY_CHAIN_ID, verifyingContract: safeAddress }, + primaryType: 'SafeMessage', + message: { message: digest }, + }; + const hash = TypedDataEncoder.hash( + typedData.domain, + withoutDomainType(typedData.types), + typedData.message + ); + + const existing = getSession(safeIndex); + if (existing) { + if (existing.hash === hash && sameRequester(existing.requester, requester)) { + // The identical request from the SAME page again (retried without + // navigating) — resume: the collected signatures are still valid + // for this hash. + return signSafeMessage(safeIndex, undefined, existing.token); + } + if (!requesterGone(existing)) { + // A different request, or the same digest from a DIFFERENT page: + // never resume or replace someone else's live ceremony. + throw codedError( + 'Another signature request is already open for this account — finish or cancel it first', + SAFE_MESSAGE_EXISTS + ); + } + // The requesting page provably no longer exists — dead leftover. + discardSession(safeIndex); + } + + acquire(safeIndex); + try { + const entry = { + token: randomUUID(), + requester: requester + ? { origin: requester.origin ?? null, webContentsId: requester.webContentsId ?? null } + : null, + chainId: DEPLOY_CHAIN_ID, + typedData, + hash, + threshold: record.threshold, + display, + signatures: [], + createdAt: Date.now(), + }; + setSession(safeIndex, entry); + attachRequesterLifecycle(safeIndex, entry); + await collectFreeSignatures(store, safeIndex, record.owners); + } finally { + release(safeIndex); + } + return sessionState(safeIndex); +} + +/** + * Collect exactly one owner's signature (or, without an ownerIndex, + * sweep the free ones — the board runs this on open). Same semantics as + * the send flow's signSafePending: idempotent, per-safe lock, failures + * belong to the row. + * + * @param {number} safeIndex + * @param {number} [ownerIndex] + * @param {string} token - The session token from startSafeMessage + * @returns {Promise} SafeMessageState + */ +async function signSafeMessage(safeIndex, ownerIndex, token) { + const record = getSafeRecord(safeIndex); + requireSession(safeIndex, token); + await signEntryOwner({ store, safeIndex, ownerIndex, ownerIndexes: record.owners }); + return sessionState(safeIndex); +} + +/** + * Close a threshold-met session and return the EIP-1271 signature: the + * owners' signatures sorted by signer and concatenated, ready to hand + * back to the dApp (which verifies via `isValidSignature` on the Safe). + * + * @param {number} safeIndex + * @param {string} token - The session token from startSafeMessage + * @returns {{signature: string}} + */ +function completeSafeMessage(safeIndex, token) { + if (isBusy(safeIndex)) { + throw codedError('Wait for the current step to finish first', SAFE_BUSY); + } + const entry = requireSession(safeIndex, token); + if (entry.signatures.length < entry.threshold) { + throw new Error( + `Not enough signatures yet (${entry.signatures.length} of ${entry.threshold})` + ); + } + // buildSignatureBytes sorts its input in place — hand it a copy. + const signature = buildSignatureBytes(entry.signatures.map((sig) => ({ ...sig }))); + discardSession(safeIndex); + return { signature }; +} + +/** + * Drop the session (collected signatures are thrown away). Idempotent + * when nothing is open; refused with someone else's token. + */ +function cancelSafeMessage(safeIndex, token) { + if (isBusy(safeIndex)) { + throw codedError('Wait for the current step to finish first', SAFE_BUSY); + } + if (!getSession(safeIndex)) { + return; // already gone — cancelling twice is fine + } + requireSession(safeIndex, token); + discardSession(safeIndex); +} + +module.exports = { + startSafeMessage, + signSafeMessage, + completeSafeMessage, + cancelSafeMessage, + getSafeMessageState, +}; diff --git a/src/main/wallet/safe/safe-messages.test.js b/src/main/wallet/safe/safe-messages.test.js new file mode 100644 index 00000000..01f1d2b6 --- /dev/null +++ b/src/main/wallet/safe/safe-messages.test.js @@ -0,0 +1,498 @@ +const { hashMessage, TypedDataEncoder, getAddress } = require('ethers'); +const { + calculateSafeMessageHash, + hashSafeMessage, + buildSignatureBytes, +} = require('@safe-global/protocol-kit'); + +const { ownerWallet } = require('./__tests__/helpers/test-owners'); + +const OWNERS = [ownerWallet(0).address, ownerWallet(2).address, ownerWallet(4).address]; +const SAFE_ADDRESS = getAddress('0x41aD4887971f90BB3fE4d83eCa65177281283261'); + +let mockTmpDir = require('os').tmpdir(); +// Fake webContents per id — sessions bind to the requesting page's +// webContents and are dropped when it navigates or is destroyed. +const mockWebContentsById = new Map(); +jest.mock('electron', () => ({ + app: { getPath: () => mockTmpDir }, + webContents: { fromId: (id) => mockWebContentsById.get(id) || null }, +})); + +function fakeWebContents(id) { + const listeners = new Map(); + const contents = { + id, + destroyed: false, + isDestroyed: () => contents.destroyed, + on: (event, fn) => { + listeners.set(event, [...(listeners.get(event) || []), fn]); + }, + removeListener: (event, fn) => { + listeners.set( + event, + (listeners.get(event) || []).filter((listener) => listener !== fn) + ); + }, + emit: (event) => { + for (const fn of [...(listeners.get(event) || [])]) fn(); + }, + listenerCount: (event) => (listeners.get(event) || []).length, + }; + mockWebContentsById.set(id, contents); + return contents; +} + +function destroyWebContents(id) { + const contents = mockWebContentsById.get(id); + if (contents) { + contents.destroyed = true; + contents.emit('destroyed'); + mockWebContentsById.delete(id); + } +} + +const mockWalletRecords = { + 0: { index: 0, name: 'Main Wallet', address: OWNERS[0], type: 'mnemonic' }, + 2: { index: 2, name: 'My Stax', address: OWNERS[1], type: 'ledger' }, + 4: { index: 4, name: 'My Phone', address: OWNERS[2], type: 'remote' }, + 5: { + index: 5, + name: 'Joint', + address: SAFE_ADDRESS, + type: 'safe', + owners: [0, 2, 4], + threshold: 2, + saltNonce: '7508', + deployed: { 100: true }, + }, + 6: { + index: 6, + name: 'Fresh', + address: SAFE_ADDRESS.replace('41', '42'), + type: 'safe', + owners: [0, 2], + threshold: 1, + saltNonce: '9', + deployed: {}, + }, + 7: { + index: 7, + name: 'Backup', + address: getAddress(SAFE_ADDRESS.toLowerCase().replace('0x41', '0x43')), + type: 'safe', + owners: [0, 2], + threshold: 1, + saltNonce: '11', + deployed: { 100: true }, + }, +}; +const mockIsVaultUnlocked = jest.fn(async () => true); +jest.mock('../../identity-manager', () => ({ + getWalletRecord: (index) => mockWalletRecords[index] || null, + isVaultUnlocked: (...args) => mockIsVaultUnlocked(...args), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', SAFE: 'safe' }, +})); + +// Distinct 65-byte signature blobs per owner so concatenation order is +// visible in completeSafeMessage's output. +const sigDataOf = { 0: '0x' + '11'.repeat(65), 2: '0x' + '22'.repeat(65), 4: '0x' + '33'.repeat(65) }; +const signatureOf = (index) => ({ + signer: OWNERS[index === 0 ? 0 : index === 2 ? 1 : 2], + data: sigDataOf[index], +}); +const mockCollectOwnerSignature = jest.fn(async ({ ownerIndex }) => signatureOf(ownerIndex)); +jest.mock('./safe-executor', () => ({ + SAFE_VERSION: '1.4.1', + collectOwnerSignature: (...args) => mockCollectOwnerSignature(...args), + pickDefaultExecutor: jest.requireActual('./safe-executor').pickDefaultExecutor, +})); + +jest.mock('../provider-manager', () => ({ + getEip1193Provider: () => ({ request: jest.fn() }), +})); + +const { + startSafeMessage, + signSafeMessage, + completeSafeMessage, + cancelSafeMessage, + getSafeMessageState, +} = require('./safe-messages'); +const { getSession, discardSession } = require('./message-sessions'); +const { SAFE_MESSAGE_EXISTS } = require('./errors'); + +// "hello" hex-encoded, the way dApps send personal_sign payloads. +const HEX_MESSAGE = '0x68656c6c6f'; + +const DAPP_TYPED_DATA = { + domain: { name: 'Test Dapp', chainId: 100, verifyingContract: '0x' + 'ab'.repeat(20) }, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Order: [{ name: 'amount', type: 'uint256' }], + }, + primaryType: 'Order', + message: { amount: '12' }, +}; + +const DISPLAY = { site: 'app.example', method: 'personal_sign' }; + +// Two live dApp pages: the legitimate requester and a second tab on a +// different site. +const TAB_A = { origin: 'app.example', webContentsId: 101 }; +const TAB_B = { origin: 'other.example', webContentsId: 202 }; + +const startPersonal = (safeIndex = 5, message = HEX_MESSAGE, requester = TAB_A) => + startSafeMessage({ + safeIndex, + request: { method: 'personal_sign', params: [message, SAFE_ADDRESS] }, + display: DISPLAY, + requester, + }); + +beforeEach(() => { + jest.clearAllMocks(); + mockIsVaultUnlocked.mockResolvedValue(true); + for (const index of [5, 6, 7]) discardSession(index); + mockWebContentsById.clear(); + fakeWebContents(TAB_A.webContentsId); + fakeWebContents(TAB_B.webContentsId); +}); + +describe('startSafeMessage', () => { + test('wraps a hex personal message: EIP-191 digest over the BYTES in the SafeMessage envelope', async () => { + const state = await startPersonal(); + + // digest = what an EOA signer / verifying dApp computes: EIP-191 + // over the decoded bytes ("hello"), never the "0x…" text as UTF-8 + const digest = hashMessage('hello'); + expect(state.hash).toBe(calculateSafeMessageHash(SAFE_ADDRESS, digest, '1.4.1', 100n)); + + // the payload every owner signs is the SafeMessage envelope + expect(mockCollectOwnerSignature).toHaveBeenCalledWith( + expect.objectContaining({ + typedData: expect.objectContaining({ + primaryType: 'SafeMessage', + domain: { chainId: 100, verifyingContract: SAFE_ADDRESS }, + message: { message: digest }, + }), + ownerIndex: 0, + }) + ); + + // only the free (mnemonic) owner was asked — devices never cold-called + expect(mockCollectOwnerSignature).toHaveBeenCalledTimes(1); + expect(state).toMatchObject({ + safeIndex: 5, + kind: 'message', + chainId: 100, + threshold: 2, + collected: 1, + complete: false, + display: DISPLAY, + owners: [ + { index: 0, type: 'mnemonic', signed: true }, + { index: 2, type: 'ledger', signed: false }, + { index: 4, type: 'remote', signed: false }, + ].map((owner) => expect.objectContaining(owner)), + }); + // the session capability every follow-up call must present + expect(typeof state.token).toBe('string'); + expect(state.token.length).toBeGreaterThanOrEqual(32); + }); + + test('plain-text personal messages hash as UTF-8', async () => { + const state = await startPersonal(5, 'gm world'); + expect(state.hash).toBe( + calculateSafeMessageHash(SAFE_ADDRESS, hashMessage('gm world'), '1.4.1', 100n) + ); + }); + + test('wraps dApp typed data (JSON-string param), matching protocol-kit hashing', async () => { + const state = await startSafeMessage({ + safeIndex: 5, + request: { + method: 'eth_signTypedData_v4', + params: [SAFE_ADDRESS, JSON.stringify(DAPP_TYPED_DATA)], + }, + display: { site: 'app.example', method: 'eth_signTypedData_v4' }, + requester: TAB_A, + }); + + const digest = TypedDataEncoder.hash( + DAPP_TYPED_DATA.domain, + { Order: DAPP_TYPED_DATA.types.Order }, + DAPP_TYPED_DATA.message + ); + expect(digest).toBe(hashSafeMessage(DAPP_TYPED_DATA)); // parity with protocol-kit + expect(state.hash).toBe(calculateSafeMessageHash(SAFE_ADDRESS, digest, '1.4.1', 100n)); + }); + + test('a 1-of-N session is complete right after the free signature', async () => { + const state = await startPersonal(7); + expect(state).toMatchObject({ collected: 1, threshold: 1, complete: true }); + }); + + test('a locked vault collects nothing', async () => { + mockIsVaultUnlocked.mockResolvedValue(false); + const state = await startPersonal(); + expect(mockCollectOwnerSignature).not.toHaveBeenCalled(); + expect(state.collected).toBe(0); + }); + + test('refuses undeployed safes, non-safe accounts, and unsupported methods', async () => { + await expect(startPersonal(6)).rejects.toThrow(/activate/i); + await expect(startPersonal(0)).rejects.toThrow(/not a Safe/i); + await expect( + startSafeMessage({ safeIndex: 5, request: { method: 'eth_sign', params: [] }, display: {} }) + ).rejects.toThrow(/unsupported/i); + }); + + test('re-requesting the SAME message from the SAME page resumes, signatures intact', async () => { + // A dApp retrying its own request (without navigating) — the + // collected signatures are still valid for the identical hash. + const { token } = await startPersonal(); + await signSafeMessage(5, 2, token); + mockCollectOwnerSignature.mockClear(); + + const resumed = await startPersonal(); + expect(resumed).toMatchObject({ collected: 2, complete: true, token }); + expect(mockCollectOwnerSignature).not.toHaveBeenCalled(); + }); + + test('the SAME message from a DIFFERENT page never resumes the session', async () => { + // Tab B asks for the identical digest while tab A's ceremony is + // live: handing B the resumed (possibly threshold-met) session + // would let B collect a signature the user approved for A. + const first = await startPersonal(); + await signSafeMessage(5, 2, first.token); + + await expect(startPersonal(5, HEX_MESSAGE, TAB_B)).rejects.toMatchObject({ + code: SAFE_MESSAGE_EXISTS, + }); + // …and A's session is untouched + expect(getSafeMessageState(5, first.token)).toMatchObject({ collected: 2, token: first.token }); + }); + + test('the same site in ANOTHER tab is another caller — no resume across tabs', async () => { + const first = await startPersonal(); + const sameSiteOtherTab = { origin: TAB_A.origin, webContentsId: TAB_B.webContentsId }; + await expect(startPersonal(5, HEX_MESSAGE, sameSiteOtherTab)).rejects.toMatchObject({ + code: SAFE_MESSAGE_EXISTS, + }); + expect(getSafeMessageState(5, first.token)).not.toBeNull(); + }); + + test('a NEW request is refused while another live request is open (no silent replace)', async () => { + const first = await startPersonal(); + await signSafeMessage(5, 2, first.token); + + await expect(startPersonal(5, 'a different message', TAB_B)).rejects.toMatchObject({ + code: SAFE_MESSAGE_EXISTS, + }); + // the live session keeps its identity and signatures + expect(getSafeMessageState(5, first.token)).toMatchObject({ + collected: 2, + hash: calculateSafeMessageHash(SAFE_ADDRESS, hashMessage('hello'), '1.4.1', 100n), + }); + }); + + test('a leftover from a CLOSED page is dead — a new request replaces it', async () => { + const first = await startPersonal(); + destroyWebContents(TAB_A.webContentsId); + + const replaced = await startPersonal(5, 'a different message', TAB_B); + expect(replaced.collected).toBe(1); // fresh session, fresh free sweep + expect(replaced.token).not.toBe(first.token); + expect(replaced.hash).toBe( + calculateSafeMessageHash(SAFE_ADDRESS, hashMessage('a different message'), '1.4.1', 100n) + ); + // the dead session's token opens nothing + expect(getSafeMessageState(5, first.token)).toBeNull(); + }); +}); + +describe('session lifecycle follows the requesting page', () => { + test('navigating the requesting page drops its session', async () => { + const { token } = await startPersonal(); + mockWebContentsById.get(TAB_A.webContentsId).emit('did-navigate'); + expect(getSafeMessageState(5, token)).toBeNull(); + }); + + test('destroying the requesting page drops its session', async () => { + const { token } = await startPersonal(); + destroyWebContents(TAB_A.webContentsId); + expect(getSafeMessageState(5, token)).toBeNull(); + }); + + test('completion unhooks the lifecycle listeners', async () => { + const { token } = await startPersonal(7); // 1-of-N, complete right away + completeSafeMessage(7, token); + + const contents = mockWebContentsById.get(TAB_A.webContentsId); + expect(contents.listenerCount('destroyed')).toBe(0); + expect(contents.listenerCount('did-navigate')).toBe(0); + + // a later navigation must not touch the NEXT session on that safe + const next = await startPersonal(7, 'next message', TAB_B); + contents.emit('did-navigate'); + expect(getSafeMessageState(7, next.token)).not.toBeNull(); + }); +}); + +describe('signSafeMessage', () => { + test('signs exactly the requested owner; idempotent for signed ones', async () => { + const { token } = await startPersonal(); // owner 0 free-signed + mockCollectOwnerSignature.mockClear(); + + const state = await signSafeMessage(5, 2, token); + expect(state).toMatchObject({ collected: 2, complete: true }); + + mockCollectOwnerSignature.mockClear(); + const again = await signSafeMessage(5, 2, token); + expect(mockCollectOwnerSignature).not.toHaveBeenCalled(); + expect(again.collected).toBe(2); + }); + + test('ownerless call sweeps the free signatures (board reopen after unlock)', async () => { + mockIsVaultUnlocked.mockResolvedValue(false); + const { token } = await startPersonal(); + mockIsVaultUnlocked.mockResolvedValue(true); + + const state = await signSafeMessage(5, undefined, token); + expect(state.collected).toBe(1); + expect(state.owners.find((o) => o.index === 0).signed).toBe(true); + }); + + test('rejects non-owners and sessions that do not exist', async () => { + const { token } = await startPersonal(); + await expect(signSafeMessage(5, 3, token)).rejects.toThrow(/not an owner/i); + await expect(signSafeMessage(7, 0, token)).rejects.toThrow(/no signature request/i); + }); + + test('rejects a wrong or missing session token', async () => { + const { token } = await startPersonal(); + await expect(signSafeMessage(5, 2, 'not-the-token')).rejects.toThrow(/different page/i); + await expect(signSafeMessage(5, 2)).rejects.toThrow(/different page/i); + expect(mockCollectOwnerSignature).toHaveBeenCalledTimes(1); // only the free sweep at start + expect(getSafeMessageState(5, token).collected).toBe(1); // session intact + }); + + test('a device failure leaves the session and its signatures intact', async () => { + const { token } = await startPersonal(); + mockCollectOwnerSignature.mockRejectedValueOnce( + Object.assign(new Error('Ledger not connected'), { code: 'LEDGER_NOT_CONNECTED' }) + ); + await expect(signSafeMessage(5, 2, token)).rejects.toMatchObject({ + code: 'LEDGER_NOT_CONNECTED', + }); + expect(getSafeMessageState(5, token).collected).toBe(1); + }); + + test('a live ceremony blocks concurrent steps, cancel, and the send flow (shared lock)', async () => { + const { token } = await startPersonal(); + let resolveSign; + mockCollectOwnerSignature.mockImplementationOnce( + () => new Promise((resolve) => (resolveSign = resolve)) + ); + + const inFlight = signSafeMessage(5, 2, token); + await new Promise((resolve) => setImmediate(resolve)); + + await expect(signSafeMessage(5, 4, token)).rejects.toMatchObject({ code: 'SAFE_BUSY' }); + expect(() => cancelSafeMessage(5, token)).toThrow(/current step/i); + expect(() => completeSafeMessage(5, token)).toThrow(/current step/i); + // the SEND flow's guard is the same lock — one ceremony per Safe, full stop + const { cancelSafeSend } = require('./safe-transactions'); + expect(() => cancelSafeSend(5)).toThrow(/current step/i); + + resolveSign(signatureOf(2)); + await inFlight; + expect(getSafeMessageState(5, token).collected).toBe(2); + }); +}); + +describe('completeSafeMessage', () => { + test('returns the sorted concatenated signature bytes and closes the session', async () => { + const { token } = await startPersonal(); + await signSafeMessage(5, 2, token); + + const { signature } = completeSafeMessage(5, token); + + // protocol-kit sorts by signer address — byte-identical output + expect(signature).toBe(buildSignatureBytes([signatureOf(0), signatureOf(2)])); + const inOrder = [signatureOf(0), signatureOf(2)] + .sort((a, b) => a.signer.toLowerCase().localeCompare(b.signer.toLowerCase())) + .map((sig) => sig.data.slice(2)) + .join(''); + expect(signature).toBe('0x' + inOrder); + + expect(getSafeMessageState(5, token)).toBeNull(); + }); + + test('refuses below the threshold', async () => { + const { token } = await startPersonal(); + expect(() => completeSafeMessage(5, token)).toThrow(/not enough signatures/i); + expect(getSafeMessageState(5, token)).not.toBeNull(); // session survives + }); + + test('refuses a wrong or missing token — no cross-page signature handout', async () => { + const { token } = await startPersonal(); + await signSafeMessage(5, 2, token); // threshold met + + expect(() => completeSafeMessage(5, 'not-the-token')).toThrow(/different page/i); + expect(() => completeSafeMessage(5)).toThrow(/different page/i); + expect(getSafeMessageState(5, token)).toMatchObject({ collected: 2 }); // still open + }); +}); + +describe('getSafeMessageState / cancelSafeMessage', () => { + test('null when nothing is open; cancel clears; both are token-gated', async () => { + expect(getSafeMessageState(5, 'anything')).toBeNull(); + const { token } = await startPersonal(); + expect(getSafeMessageState(5, token)).not.toBeNull(); + // another page's probe sees nothing + expect(getSafeMessageState(5, 'not-the-token')).toBeNull(); + expect(getSafeMessageState(5)).toBeNull(); + cancelSafeMessage(5, token); + expect(getSafeMessageState(5, token)).toBeNull(); + // cancelling again is a no-op, not an error + expect(() => cancelSafeMessage(5, token)).not.toThrow(); + }); + + test('cancel with a wrong token is refused and leaves the session', async () => { + const { token } = await startPersonal(); + expect(() => cancelSafeMessage(5, 'not-the-token')).toThrow(/different page/i); + expect(() => cancelSafeMessage(5)).toThrow(/different page/i); + expect(getSafeMessageState(5, token)).not.toBeNull(); + }); + + test('a stale token cannot cancel a successor session', async () => { + const first = await startPersonal(); + destroyWebContents(TAB_A.webContentsId); + const next = await startPersonal(5, 'a different message', TAB_B); + + expect(() => cancelSafeMessage(5, first.token)).toThrow(/different page/i); + expect(getSafeMessageState(5, next.token)).not.toBeNull(); + }); + + test('sessions are independent from pending sends (no cross-blocking)', async () => { + // a message session on 5 does not create a pending SEND + await startPersonal(); + const { getSafeSendState } = require('./safe-transactions'); + expect(getSafeSendState(5)).toBeNull(); + }); + + test('discardSession force-drops a session regardless of token (Safe deletion path)', async () => { + const { token } = await startPersonal(); + expect(getSession(5)).not.toBeNull(); + expect(discardSession(5)).toBe(true); + expect(getSession(5)).toBeNull(); + expect(getSafeMessageState(5, token)).toBeNull(); + expect(discardSession(5)).toBe(false); // idempotent + }); +}); diff --git a/src/main/wallet/safe/safe-service.js b/src/main/wallet/safe/safe-service.js new file mode 100644 index 00000000..3216fd5e --- /dev/null +++ b/src/main/wallet/safe/safe-service.js @@ -0,0 +1,252 @@ +/** + * Safe account lifecycle above the executor: create the record (predict + * the counterfactual address, freeze the init params), report the + * deploy/funds status the UI renders as blocking states, and activate + * (deploy) on chain. + * + * "Needs funds" is a first-class state here, not an error path: every + * flow that costs gas resolves WHO pays (the default executor — first + * mnemonic owner) and whether they CAN, so the renderer can block with a + * "fund
" card instead of failing mid-flight. + * + * v1 deploys on Gnosis only (research doc Part B, decision 5); the + * chainId parameter exists so retroactive deployment on other chains can + * ship later without reshaping the API. + */ + +const crypto = require('crypto'); +const { formatEther } = require('ethers'); + +const { + getWalletRecord, + addSafeWallet, + markSafeDeployed, + WALLET_TYPES, +} = require('../../identity-manager'); +const { + predictSafeAddress, + buildDeploymentTransaction, + deploySafe, + pickDefaultExecutor, +} = require('./safe-executor'); +const { + estimateGas, + getGasPrices, + toFeeFields, + waitForTransaction, +} = require('../transaction-service'); +const { KINDS: PAYMENT_KINDS } = require('../tx-recorder'); +const { getEip1193Provider } = require('../provider-manager'); +const { codedError, SAFE_NEEDS_FUNDS } = require('./errors'); + +/** v1: Safes deploy on Gnosis only (decision 5). */ +const DEPLOY_CHAIN_ID = 100; + +/** 128-bit random decimal salt — one per created Safe, then frozen. */ +function generateSaltNonce() { + return BigInt('0x' + crypto.randomBytes(16).toString('hex')).toString(10); +} + +function getSafeRecord(index) { + const record = getWalletRecord(index); + if (!record || record.type !== WALLET_TYPES.SAFE) { + throw new Error(`Wallet ${index} is not a Safe account`); + } + return record; +} + +function resolveOwnerAddresses(ownerIndexes) { + return ownerIndexes.map((ownerIndex) => { + const record = getWalletRecord(ownerIndex); + if (!record) { + throw new Error(`Owner wallet index ${ownerIndex} does not exist`); + } + if (!record.address) { + throw new Error(`Owner "${record.name}" has no address yet — unlock the vault once first`); + } + return record.address; + }); +} + +/** Raw uncached read — long-lived ethers providers can serve stale state. */ +function chainRead(chainId, method, params) { + return getEip1193Provider(chainId).request({ method, params }); +} + +/** + * Create a Safe account record: resolve the owners' addresses, generate + * the salt, predict the CREATE2 address, store everything frozen. + * + * @param {Object} params + * @param {string} params.name - Display name ('' → auto "Safe N") + * @param {number[]} params.ownerIndexes - Wallet indexes of the owners + * @param {number} params.threshold - 1 (of 2) or 2 (of 3) + * @returns {Promise} The stored record + */ +async function createSafeAccount({ name, ownerIndexes, threshold }) { + const owners = resolveOwnerAddresses(ownerIndexes); + const saltNonce = generateSaltNonce(); + const address = await predictSafeAddress({ + owners, + threshold, + saltNonce, + chainId: DEPLOY_CHAIN_ID, + }); + return addSafeWallet(name, { address, owners: ownerIndexes, threshold, saltNonce }); +} + +/** + * Deployment truth: the record short-circuits (deployment is permanent), + * otherwise the chain is asked and a positive answer heals the record + * (covers deploys that confirmed after the app quit). + */ +async function isDeployedOnChain(record, chainId) { + if (record.deployed?.[chainId]) { + return true; + } + const code = await chainRead(chainId, 'eth_getCode', [record.address, 'latest']); + if (code && code !== '0x') { + await markSafeDeployed(record.index, chainId); + return true; + } + return false; +} + +/** + * One pass over everything activation depends on: deployment truth, + * executor, and (when a quote is needed) the built deployment tx with + * its cost against the executor's balance. `deployment` is returned so + * activateSafe can broadcast the exact tx that was quoted instead of + * rebuilding it. + */ +async function assessActivation(record) { + const chainId = DEPLOY_CHAIN_ID; + const deployed = await isDeployedOnChain(record, chainId); + + let executorIndex = null; + let executorAddress = null; + try { + executorIndex = pickDefaultExecutor(record.owners); + executorAddress = getWalletRecord(executorIndex).address; + } catch { + // No mnemonic owner — nothing local can pay gas; surfaced below. + } + + const base = { deployed, chainId, executorIndex, executorAddress }; + if (deployed) { + return { status: { ...base, executorBalance: null, estimatedCost: null, needsFunds: false } }; + } + if (executorIndex === null) { + return { status: { ...base, executorBalance: null, estimatedCost: null, needsFunds: true } }; + } + + let deployment; + const [{ gasLimit }, gasPrices, balanceHex] = await Promise.all([ + buildDeploymentTransaction({ + owners: resolveOwnerAddresses(record.owners), + threshold: record.threshold, + saltNonce: record.saltNonce, + chainId, + }).then((built) => { + deployment = built; + return estimateGas({ + from: executorAddress, + to: built.to, + value: '0', + data: built.data, + chainId, + }); + }), + getGasPrices(chainId), + chainRead(chainId, 'eth_getBalance', [executorAddress, 'latest']), + ]); + + const fees = toFeeFields(gasPrices); + const estimatedCost = BigInt(gasLimit) * BigInt(fees.maxFeePerGas ?? fees.gasPrice); + const executorBalance = BigInt(balanceHex); + + return { + deployment, + status: { + ...base, + executorBalance: executorBalance.toString(), + estimatedCost: estimatedCost.toString(), + needsFunds: executorBalance < estimatedCost, + }, + }; +} + +/** + * Everything the UI needs to render the account's blocking states: + * deployed / ready-to-activate / needs-funds / no-local-executor. + * + * @param {number} index - Wallet index of the safe record + * @returns {Promise<{deployed: boolean, chainId: number, + * executorIndex: number|null, executorAddress: string|null, + * executorBalance: string|null, estimatedCost: string|null, + * needsFunds: boolean}>} Balances/costs in wei decimal strings + */ +async function getSafeStatus(index) { + const { status } = await assessActivation(getSafeRecord(index)); + return status; +} + +/** + * Deploy the Safe's contract with the record's frozen init params. Waits + * for confirmation and only then marks the record deployed. + * + * Throws with `code: 'SAFE_NEEDS_FUNDS'` when nothing can pay — callers + * should render getSafeStatus's blocking state instead of retrying. + * + * @param {number} index - Wallet index of the safe record + * @returns {Promise<{safeAddress: string, hash?: string, alreadyDeployed?: boolean}>} + */ +async function activateSafe(index) { + const record = getSafeRecord(index); + const { status, deployment } = await assessActivation(record); + + if (status.deployed) { + return { safeAddress: record.address, alreadyDeployed: true }; + } + if (status.executorIndex === null || status.needsFunds) { + const message = + status.executorIndex === null + ? 'None of the owners is a browser account that could pay the activation fee' + : `Fund ${status.executorAddress} with at least ${formatEther(status.estimatedCost)} xDAI to activate`; + throw codedError(message, SAFE_NEEDS_FUNDS); + } + + const { safeAddress, tx } = await deploySafe({ + owners: resolveOwnerAddresses(record.owners), + threshold: record.threshold, + saltNonce: record.saltNonce, + chainId: DEPLOY_CHAIN_ID, + executorIndex: status.executorIndex, + deployment, + // The executor paid gas to create the safe: from = executor + // (tx-recorder default), the safe is what came into existence. + record: { + kind: PAYMENT_KINDS.SAFE_DEPLOY, + toAddress: record.address, + amount: '0', + metadata: { safeAddress: record.address }, + }, + }); + + const receipt = await waitForTransaction(tx.hash, DEPLOY_CHAIN_ID); + if (receipt.status !== 'confirmed') { + throw new Error('Safe deployment transaction failed on chain'); + } + await markSafeDeployed(index, DEPLOY_CHAIN_ID); + return { safeAddress, hash: tx.hash }; +} + +module.exports = { + DEPLOY_CHAIN_ID, + chainRead, + getSafeRecord, + resolveOwnerAddresses, + createSafeAccount, + getSafeStatus, + activateSafe, +}; diff --git a/src/main/wallet/safe/safe-service.test.js b/src/main/wallet/safe/safe-service.test.js new file mode 100644 index 00000000..2217c530 --- /dev/null +++ b/src/main/wallet/safe/safe-service.test.js @@ -0,0 +1,235 @@ +const { ownerWallet } = require('./__tests__/helpers/test-owners'); + +const OWNERS = [ownerWallet(0).address, ownerWallet(2).address, ownerWallet(4).address]; +const SAFE_ADDRESS = '0x41aD4887971f90BB3fE4d83eCa65177281283261'; + +const mockWalletRecords = { + 0: { index: 0, name: 'Main Wallet', address: OWNERS[0], type: 'mnemonic' }, + 2: { index: 2, name: 'My Stax', address: OWNERS[1], type: 'ledger' }, + 4: { index: 4, name: 'My Phone', address: OWNERS[2], type: 'remote' }, + 5: { + index: 5, + name: 'Joint', + address: SAFE_ADDRESS, + type: 'safe', + owners: [0, 2], + threshold: 1, + saltNonce: '7508', + deployed: {}, + }, +}; + +const mockAddSafeWallet = jest.fn(async (name, params) => ({ index: 9, name, type: 'safe', ...params })); +const mockMarkSafeDeployed = jest.fn(async () => {}); +jest.mock('../../identity-manager', () => ({ + getWalletRecord: (index) => mockWalletRecords[index] || null, + addSafeWallet: (...args) => mockAddSafeWallet(...args), + markSafeDeployed: (...args) => mockMarkSafeDeployed(...args), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', SAFE: 'safe' }, +})); + +const mockPredictSafeAddress = jest.fn(async () => SAFE_ADDRESS); +const mockBuildDeploymentTransaction = jest.fn(async () => ({ + safeAddress: SAFE_ADDRESS, + to: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + value: '0', + data: '0xdeadbeef', +})); +const mockDeploySafe = jest.fn(async () => ({ safeAddress: SAFE_ADDRESS, tx: { hash: '0x' + 'ab'.repeat(32) } })); +jest.mock('./safe-executor', () => ({ + predictSafeAddress: (...args) => mockPredictSafeAddress(...args), + buildDeploymentTransaction: (...args) => mockBuildDeploymentTransaction(...args), + deploySafe: (...args) => mockDeploySafe(...args), + pickDefaultExecutor: jest.requireActual('./safe-executor').pickDefaultExecutor, +})); + +const mockEstimateGas = jest.fn(async () => ({ gasLimit: '300000' })); +const mockGetGasPrices = jest.fn(async () => ({ + type: 'eip1559', + maxFeePerGas: '2000000000', // cost = 300000 * 2 gwei = 0.0006 ether + maxPriorityFeePerGas: '1000000000', +})); +const mockWaitForTransaction = jest.fn(async () => ({ status: 'confirmed' })); +jest.mock('../transaction-service', () => ({ + estimateGas: (...args) => mockEstimateGas(...args), + getGasPrices: (...args) => mockGetGasPrices(...args), + // the pure fee-shape mapper stays real so its behaviour is under test + toFeeFields: jest.requireActual('../transaction-service').toFeeFields, + waitForTransaction: (...args) => mockWaitForTransaction(...args), +})); + +// Raw chain reads: eth_getCode (deployment truth) + eth_getBalance. +const mockRpcRequest = jest.fn(); +jest.mock('../provider-manager', () => ({ + getEip1193Provider: () => ({ request: (...args) => mockRpcRequest(...args) }), +})); + +const { + DEPLOY_CHAIN_ID, + createSafeAccount, + getSafeStatus, + activateSafe, +} = require('./safe-service'); + +const DEPLOY_COST = 300000n * 2000000000n; // gasLimit × maxFeePerGas + +function stubChainState({ code = '0x', balance = 0n } = {}) { + mockRpcRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') return code; + if (method === 'eth_getBalance') return '0x' + balance.toString(16); + throw new Error(`unexpected rpc ${method}`); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + stubChainState(); +}); + +describe('createSafeAccount', () => { + test('predicts the address from resolved owner addresses and stores the record', async () => { + const wallet = await createSafeAccount({ name: 'Joint', ownerIndexes: [0, 2], threshold: 1 }); + + const predictArgs = mockPredictSafeAddress.mock.calls[0][0]; + expect(predictArgs.owners).toEqual([OWNERS[0], OWNERS[1]]); + expect(predictArgs.threshold).toBe(1); + expect(predictArgs.chainId).toBe(DEPLOY_CHAIN_ID); + expect(predictArgs.saltNonce).toMatch(/^\d+$/); + + const [name, stored] = mockAddSafeWallet.mock.calls[0]; + expect(name).toBe('Joint'); + expect(stored).toEqual({ + address: SAFE_ADDRESS, + owners: [0, 2], + threshold: 1, + saltNonce: predictArgs.saltNonce, + }); + expect(wallet.type).toBe('safe'); + }); + + test('generates a fresh salt per account', async () => { + await createSafeAccount({ name: 'A', ownerIndexes: [0, 2], threshold: 1 }); + await createSafeAccount({ name: 'B', ownerIndexes: [0, 2], threshold: 1 }); + const [saltA, saltB] = mockPredictSafeAddress.mock.calls.map(([args]) => args.saltNonce); + expect(saltA).not.toBe(saltB); + }); + + test('rejects owners without a known address', async () => { + mockWalletRecords[7] = { index: 7, name: 'Locked', address: null, type: 'mnemonic' }; + await expect( + createSafeAccount({ name: 'Bad', ownerIndexes: [0, 7], threshold: 1 }) + ).rejects.toThrow(/Locked.*no address/i); + delete mockWalletRecords[7]; + }); +}); + +describe('getSafeStatus', () => { + test('undeployed with a funded executor: no blocking state', async () => { + stubChainState({ code: '0x', balance: DEPLOY_COST * 2n }); + + const status = await getSafeStatus(5); + + expect(status).toEqual({ + deployed: false, + chainId: DEPLOY_CHAIN_ID, + executorIndex: 0, + executorAddress: OWNERS[0], + executorBalance: (DEPLOY_COST * 2n).toString(), + estimatedCost: DEPLOY_COST.toString(), + needsFunds: false, + }); + // gas was estimated against the real deployment calldata + expect(mockEstimateGas).toHaveBeenCalledWith( + expect.objectContaining({ from: OWNERS[0], data: '0xdeadbeef', chainId: DEPLOY_CHAIN_ID }) + ); + }); + + test('undeployed with a broke executor: needsFunds blocks', async () => { + stubChainState({ code: '0x', balance: DEPLOY_COST - 1n }); + const status = await getSafeStatus(5); + expect(status.needsFunds).toBe(true); + }); + + test('code on chain marks the record deployed (self-heal) and skips the quote', async () => { + stubChainState({ code: '0x60806040' }); + + const status = await getSafeStatus(5); + + expect(status.deployed).toBe(true); + expect(status.needsFunds).toBe(false); + expect(status.estimatedCost).toBeNull(); + expect(mockMarkSafeDeployed).toHaveBeenCalledWith(5, DEPLOY_CHAIN_ID); + expect(mockEstimateGas).not.toHaveBeenCalled(); + }); + + test('record already marked deployed skips the chain read entirely', async () => { + mockWalletRecords[5].deployed = { [DEPLOY_CHAIN_ID]: true }; + const status = await getSafeStatus(5); + expect(status.deployed).toBe(true); + expect(mockRpcRequest).not.toHaveBeenCalled(); + mockWalletRecords[5].deployed = {}; + }); + + test('a safe with no local gas-paying owner reports executorIndex null', async () => { + mockWalletRecords[6] = { ...mockWalletRecords[5], index: 6, owners: [2, 4] }; + const status = await getSafeStatus(6); + expect(status.executorIndex).toBeNull(); + expect(status.needsFunds).toBe(true); + delete mockWalletRecords[6]; + }); + + test('rejects non-safe accounts', async () => { + await expect(getSafeStatus(0)).rejects.toThrow(/not a Safe/i); + }); +}); + +describe('activateSafe', () => { + test('deploys with frozen init params, waits for confirmation, marks deployed', async () => { + stubChainState({ code: '0x', balance: DEPLOY_COST * 2n }); + + const result = await activateSafe(5); + + expect(mockDeploySafe).toHaveBeenCalledWith({ + owners: [OWNERS[0], OWNERS[1]], + threshold: 1, + saltNonce: '7508', + chainId: DEPLOY_CHAIN_ID, + executorIndex: 0, + // the quoted deployment is reused — no second protocol-kit build + deployment: await mockBuildDeploymentTransaction.mock.results[0].value, + // the deploy lands in payment history + record: { + kind: 'safe-deploy', + toAddress: SAFE_ADDRESS, + amount: '0', + metadata: { safeAddress: SAFE_ADDRESS }, + }, + }); + expect(mockBuildDeploymentTransaction).toHaveBeenCalledTimes(1); + expect(mockWaitForTransaction).toHaveBeenCalledWith('0x' + 'ab'.repeat(32), DEPLOY_CHAIN_ID); + expect(mockMarkSafeDeployed).toHaveBeenCalledWith(5, DEPLOY_CHAIN_ID); + expect(result).toEqual({ safeAddress: SAFE_ADDRESS, hash: '0x' + 'ab'.repeat(32) }); + }); + + test('blocks with SAFE_NEEDS_FUNDS when the executor cannot pay', async () => { + stubChainState({ code: '0x', balance: 0n }); + + await expect(activateSafe(5)).rejects.toMatchObject({ code: 'SAFE_NEEDS_FUNDS' }); + expect(mockDeploySafe).not.toHaveBeenCalled(); + }); + + test('is a no-op when already deployed', async () => { + stubChainState({ code: '0x60806040' }); + const result = await activateSafe(5); + expect(result).toEqual({ safeAddress: SAFE_ADDRESS, alreadyDeployed: true }); + expect(mockDeploySafe).not.toHaveBeenCalled(); + }); + + test('does not mark deployed when the deploy tx fails', async () => { + stubChainState({ code: '0x', balance: DEPLOY_COST * 2n }); + mockWaitForTransaction.mockResolvedValueOnce({ status: 'failed' }); + + await expect(activateSafe(5)).rejects.toThrow(/failed/i); + expect(mockMarkSafeDeployed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/wallet/safe/safe-transactions.js b/src/main/wallet/safe/safe-transactions.js new file mode 100644 index 00000000..376ea201 --- /dev/null +++ b/src/main/wallet/safe/safe-transactions.js @@ -0,0 +1,280 @@ +/** + * Safe send orchestration — a user-paced task board, not a pipeline. + * + * Collecting owner signatures is fundamentally asynchronous: devices + * need to be plugged in or fetched, and the user decides which owner + * signs when. So the API is granular — build once, sign one owner at a + * time, execute as its own idempotent step — and every intermediate + * state is persisted (pending-store, one pending SafeTx per Safe) so a + * rejection, an unreachable device, or an app restart never loses + * collected signatures. A signature failure is an OWNER-row fact, never + * a transaction failure. + * + * Free signatures are still free: mnemonic owners are signed silently + * when the SafeTx is created (vault unlocked, zero ceremony). Devices + * are never cold-called — only signSafePending, user-initiated, touches + * them. + * + * Execution is nonce-guarded: if the Safe's on-chain nonce moved past + * the pending SafeTx (a broadcast that silently landed, or the user + * executing via app.safe.global), the pending tx can never execute — + * it flips to a terminal 'superseded' state instead of retry-looping. + */ + +const { KINDS: PAYMENT_KINDS } = require('../tx-recorder'); +const { buildSafeTransaction, execTransaction, pickDefaultExecutor } = require('./safe-executor'); +const { + getSafeRecord, + resolveOwnerAddresses, + chainRead, + DEPLOY_CHAIN_ID, +} = require('./safe-service'); +const { getPending, setPending, clearPending, listPendingIndexes } = require('./pending-store'); +const { codedError, SAFE_BUSY, SAFE_PENDING_EXISTS, SAFE_NEEDS_FUNDS } = require('./errors'); +const { + acquire, + release, + isBusy, + ownersView, + collectFreeSignatures, + signEntryOwner, +} = require('./signature-collection'); + +// The signature-collection store over the persisted pending-SafeTx +// entries; the SafeTx hash is the entry's identity. +const store = { get: getPending, set: setPending, idOf: (entry) => entry.safeTxHash }; + +/** Safe.nonce() — raw uncached read. */ +async function chainSafeNonce(safeAddress) { + return BigInt( + await chainRead(DEPLOY_CHAIN_ID, 'eth_call', [ + { to: safeAddress, data: '0xaffed0e0' /* nonce() */ }, + 'latest', + ]) + ); +} + +/** + * The board's render model. Pure — never touches the chain, never + * throws for a render call. Null when nothing is pending. + * + * @returns {{safeIndex: number, chainId: number, safeTxHash: string, + * threshold: number, collected: number, + * owners: Array<{index: number, address: string|null, type: string, signed: boolean}>, + * executorIndex: number|null, display: Object, createdAt: number, + * status: 'awaiting'|'superseded'}|null} + */ +function getSafeSendState(safeIndex) { + const pending = getPending(safeIndex); + if (!pending) { + return null; + } + + let ownerIndexes = []; + try { + ownerIndexes = getSafeRecord(safeIndex).owners; + } catch { + // record gone — render what the pending entry alone supports + } + const owners = ownersView(ownerIndexes, pending.signatures); + + let executorIndex = null; + try { + executorIndex = pickDefaultExecutor(ownerIndexes); + } catch { + // no local gas payer — the board says so at execution time + } + + return { + safeIndex, + chainId: pending.chainId, + safeTxHash: pending.safeTxHash, + threshold: pending.threshold, + collected: pending.signatures.length, + owners, + executorIndex, + display: pending.display, + createdAt: pending.createdAt, + status: pending.superseded ? 'superseded' : 'awaiting', + }; +} + +/** + * Build a new SafeTx, persist it, and silently collect the free + * signatures (mnemonic owners, vault unlocked — no ceremony). Never + * touches a device and never executes: the caller (the signing board) + * drives both, per user action. + * + * @param {Object} params + * @param {number} params.safeIndex + * @param {Object} params.tx - {to, value, data?} the Safe should execute + * @param {Object} params.display - Presentation-ready payment facts, + * persisted verbatim for the board/card: {toAddress, recipientName?, + * asset, amount (atomic), symbol, decimals, formattedAmount} + * @returns {Promise} SafeSendState + */ +async function startSafeSend({ safeIndex, tx, display }) { + const record = getSafeRecord(safeIndex); + if (!record.deployed?.[DEPLOY_CHAIN_ID]) { + throw new Error('Activate this account on Gnosis before sending'); + } + if (getPending(safeIndex)) { + throw codedError( + 'A transaction is already waiting for signatures — finish or discard it first', + SAFE_PENDING_EXISTS + ); + } + + acquire(safeIndex); + try { + const built = await buildSafeTransaction({ + chainId: DEPLOY_CHAIN_ID, + safe: { ...record, owners: resolveOwnerAddresses(record.owners) }, + tx, + }); + setPending(safeIndex, { + chainId: DEPLOY_CHAIN_ID, + safeAddress: built.safeAddress, + safeTxData: built.safeTxData, + safeTxHash: built.safeTxHash, + typedData: built.typedData, + threshold: record.threshold, + display, + signatures: [], + createdAt: Date.now(), + }); + await collectFreeSignatures(store, safeIndex, record.owners); + } finally { + release(safeIndex); + } + return getSafeSendState(safeIndex); +} + +/** + * Collect exactly one owner's signature — the user tapped that row, the + * device is in their hand. Persists immediately. Errors (device not + * connected, rejection, locked vault) leave the pending SafeTx intact; + * they belong to the row, not the transaction. + * + * Called without an ownerIndex it instead sweeps the FREE signatures + * (unsigned mnemonic owners, vault unlocked) — the board runs this on + * open to cover a vault that was locked when the SafeTx was created. + * + * @param {number} safeIndex + * @param {number} [ownerIndex] + * @returns {Promise} SafeSendState + */ +async function signSafePending(safeIndex, ownerIndex) { + const record = getSafeRecord(safeIndex); + const pending = getPending(safeIndex); + if (!pending) { + throw new Error('No pending transaction for this account'); + } + if (pending.superseded) { + throw new Error('This transaction can no longer be signed — discard it'); + } + + await signEntryOwner({ store, safeIndex, ownerIndex, ownerIndexes: record.owners }); + return getSafeSendState(safeIndex); +} + +/** + * Execute the fully-signed SafeTx through the executor EOA. Idempotent + * and nonce-guarded: called automatically by the board at threshold and + * re-called safely after any failure (signatures survive — the SafeTx + * nonce only advances when an execution lands). + * + * @param {number} safeIndex + * @returns {Promise} SafeSendState; on success carries + * `executed: {hash, explorerUrl}` (and the pending entry is cleared) + */ +async function executeSafePending(safeIndex) { + const record = getSafeRecord(safeIndex); + const pending = getPending(safeIndex); + if (!pending) { + throw new Error('No pending transaction for this account'); + } + + acquire(safeIndex); + try { + // Nonce guard: a moved nonce means this SafeTx can never execute — + // an earlier broadcast landed after all, or the Safe was used + // elsewhere (app.safe.global). Terminal, not retryable. + const chainNonce = await chainSafeNonce(pending.safeAddress); + if (chainNonce > BigInt(pending.safeTxData.nonce)) { + pending.superseded = true; + setPending(safeIndex, pending); + return getSafeSendState(safeIndex); + } + + if (pending.signatures.length < pending.threshold) { + throw new Error( + `Not enough signatures yet (${pending.signatures.length} of ${pending.threshold})` + ); + } + + let result; + try { + result = await execTransaction({ + chainId: pending.chainId, + safeAddress: pending.safeAddress, + safeTxData: pending.safeTxData, + signatures: pending.signatures, + executorIndex: pickDefaultExecutor(record.owners), + record: { + kind: PAYMENT_KINDS.SAFE_SEND, + fromAddress: pending.safeAddress, + toAddress: pending.display?.toAddress, + asset: pending.display?.asset ?? null, + amount: pending.display?.amount, + // dApp-initiated sends carry the requesting site. + ...(pending.display?.site ? { origin: pending.display.site } : {}), + metadata: { safeAddress: pending.safeAddress, safeTxHash: pending.safeTxHash }, + }, + }); + } catch (err) { + if (/insufficient funds/i.test(err.message)) { + err.code = SAFE_NEEDS_FUNDS; + } + throw err; + } + + const state = getSafeSendState(safeIndex); + clearPending(safeIndex); + return { ...state, status: 'executed', executed: { hash: result.hash, explorerUrl: result.explorerUrl } }; + } finally { + release(safeIndex); + } +} + +/** + * Render models of every pending SafeTx, across all Safes — the wallet's + * "unfinished transactions" overview. + * @returns {Array} SafeSendStates, oldest first + */ +function getAllSafeSendStates() { + return listPendingIndexes() + .map((safeIndex) => getSafeSendState(safeIndex)) + .filter(Boolean) + .sort((a, b) => a.createdAt - b.createdAt); +} + +/** + * Discard the pending SafeTx (collected signatures are thrown away). + * Refused while a signature ceremony or the execution is live. + */ +function cancelSafeSend(safeIndex) { + if (isBusy(safeIndex)) { + throw codedError('Wait for the current step to finish first', SAFE_BUSY); + } + clearPending(safeIndex); +} + +module.exports = { + startSafeSend, + signSafePending, + executeSafePending, + getSafeSendState, + getAllSafeSendStates, + cancelSafeSend, +}; diff --git a/src/main/wallet/safe/safe-transactions.test.js b/src/main/wallet/safe/safe-transactions.test.js new file mode 100644 index 00000000..bcaf4586 --- /dev/null +++ b/src/main/wallet/safe/safe-transactions.test.js @@ -0,0 +1,331 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { ownerWallet } = require('./__tests__/helpers/test-owners'); + +const OWNERS = [ownerWallet(0).address, ownerWallet(2).address, ownerWallet(4).address]; +const SAFE_ADDRESS = '0x41aD4887971f90BB3fE4d83eCa65177281283261'; +const SAFE_TX_HASH = '0x' + 'cd'.repeat(32); +const TX_HASH = '0x' + 'ab'.repeat(32); + +let mockTmpDir; +jest.mock('electron', () => ({ + app: { getPath: () => mockTmpDir }, +})); + +const mockWalletRecords = { + 0: { index: 0, name: 'Main Wallet', address: OWNERS[0], type: 'mnemonic' }, + 2: { index: 2, name: 'My Stax', address: OWNERS[1], type: 'ledger' }, + 4: { index: 4, name: 'My Phone', address: OWNERS[2], type: 'remote' }, + 5: { + index: 5, + name: 'Joint', + address: SAFE_ADDRESS, + type: 'safe', + owners: [0, 2, 4], + threshold: 2, + saltNonce: '7508', + deployed: { 100: true }, + }, + 6: { + index: 6, + name: 'Fresh', + address: SAFE_ADDRESS.replace('41', '42'), + type: 'safe', + owners: [0, 2], + threshold: 1, + saltNonce: '9', + deployed: {}, + }, +}; +const mockIsVaultUnlocked = jest.fn(async () => true); +jest.mock('../../identity-manager', () => ({ + getWalletRecord: (index) => mockWalletRecords[index] || null, + isVaultUnlocked: (...args) => mockIsVaultUnlocked(...args), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', SAFE: 'safe' }, +})); + +const builtResult = { + safeAddress: SAFE_ADDRESS, + deployed: true, + safeTxData: { to: OWNERS[2], value: '1000', data: '0x', nonce: 3 }, + safeTxHash: SAFE_TX_HASH, + typedData: { domain: {}, types: {}, message: {} }, +}; +const signatureOf = (index) => ({ + signer: OWNERS[index === 0 ? 0 : index === 2 ? 1 : 2], + data: '0x' + 'ee'.repeat(65), +}); + +const mockBuildSafeTransaction = jest.fn(async () => builtResult); +const mockCollectOwnerSignature = jest.fn(async ({ ownerIndex }) => signatureOf(ownerIndex)); +const mockExecTransaction = jest.fn(async () => ({ + hash: TX_HASH, + explorerUrl: `https://gnosisscan.io/tx/${TX_HASH}`, + recorded: true, +})); +jest.mock('./safe-executor', () => ({ + buildSafeTransaction: (...args) => mockBuildSafeTransaction(...args), + collectOwnerSignature: (...args) => mockCollectOwnerSignature(...args), + execTransaction: (...args) => mockExecTransaction(...args), + pickDefaultExecutor: jest.requireActual('./safe-executor').pickDefaultExecutor, +})); + +// Chain reads (the Safe nonce guard). +const mockRpcRequest = jest.fn(async () => '0x3'); // == pending nonce → executable +jest.mock('../provider-manager', () => ({ + getEip1193Provider: () => ({ request: (...args) => mockRpcRequest(...args) }), +})); + +const { + startSafeSend, + signSafePending, + executeSafePending, + getSafeSendState, + getAllSafeSendStates, + cancelSafeSend, +} = require('./safe-transactions'); +const { getPending, clearPending } = require('./pending-store'); + +const DISPLAY = { + toAddress: OWNERS[2], + asset: null, + amount: '1000', + symbol: 'xDAI', + decimals: 18, + formattedAmount: '0.000000000000001', +}; +const TX = { to: OWNERS[2], value: '1000', data: '0x' }; + +const start = (safeIndex = 5) => startSafeSend({ safeIndex, tx: TX, display: DISPLAY }); + +beforeEach(() => { + mockTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'safe-pending-')); + jest.clearAllMocks(); + mockIsVaultUnlocked.mockResolvedValue(true); + mockRpcRequest.mockResolvedValue('0x3'); + for (const index of [5, 6]) clearPending(index); +}); + +afterEach(() => { + fs.rmSync(mockTmpDir, { recursive: true, force: true }); +}); + +describe('startSafeSend', () => { + test('builds, persists, silently signs the mnemonic owner only, never executes', async () => { + const state = await start(); + + expect(mockBuildSafeTransaction).toHaveBeenCalledWith({ + chainId: 100, + // owners resolved to ADDRESSES for the executor layer + safe: { ...mockWalletRecords[5], owners: OWNERS }, + tx: TX, + }); + // only the free (mnemonic) owner was asked — devices are never cold-called + expect(mockCollectOwnerSignature).toHaveBeenCalledTimes(1); + expect(mockCollectOwnerSignature).toHaveBeenCalledWith({ typedData: builtResult.typedData, ownerIndex: 0 }); + expect(mockExecTransaction).not.toHaveBeenCalled(); + + expect(state).toMatchObject({ + safeIndex: 5, + chainId: 100, + safeTxHash: SAFE_TX_HASH, + threshold: 2, + collected: 1, + status: 'awaiting', + display: DISPLAY, + executorIndex: 0, + owners: [ + { index: 0, type: 'mnemonic', signed: true }, + { index: 2, type: 'ledger', signed: false }, + { index: 4, type: 'remote', signed: false }, + ].map((owner) => expect.objectContaining(owner)), + }); + expect(getPending(5).signatures).toEqual([signatureOf(0)]); + }); + + test('a locked vault skips the silent signing entirely', async () => { + mockIsVaultUnlocked.mockResolvedValue(false); + const state = await start(); + expect(mockCollectOwnerSignature).not.toHaveBeenCalled(); + expect(state.collected).toBe(0); + }); + + test('an auto-sign failure degrades the owner to a manual row instead of failing', async () => { + mockCollectOwnerSignature.mockRejectedValueOnce(new Error('Vault is locked')); + const state = await start(); + expect(state.collected).toBe(0); + expect(state.status).toBe('awaiting'); + }); + + test('refuses a second pending transaction (typed code)', async () => { + await start(); + await expect(start()).rejects.toMatchObject({ code: 'SAFE_PENDING_EXISTS' }); + }); + + test('refuses safes not yet deployed and non-safe accounts', async () => { + await expect(start(6)).rejects.toThrow(/activate/i); + await expect(start(0)).rejects.toThrow(/not a Safe/i); + }); +}); + +describe('signSafePending', () => { + test('signs exactly the requested owner and persists it', async () => { + await start(); // collected: owner 0 + mockCollectOwnerSignature.mockClear(); + + const state = await signSafePending(5, 2); + + expect(mockCollectOwnerSignature).toHaveBeenCalledWith({ typedData: builtResult.typedData, ownerIndex: 2 }); + expect(state.collected).toBe(2); + expect(state.owners.find((o) => o.index === 2).signed).toBe(true); + expect(getPending(5).signatures).toHaveLength(2); + expect(mockExecTransaction).not.toHaveBeenCalled(); // execution is a separate step + }); + + test('is idempotent for an already-signed owner', async () => { + await start(); + mockCollectOwnerSignature.mockClear(); + const state = await signSafePending(5, 0); + expect(mockCollectOwnerSignature).not.toHaveBeenCalled(); + expect(state.collected).toBe(1); + }); + + test('a device failure leaves the pending transaction intact', async () => { + await start(); + mockCollectOwnerSignature.mockRejectedValueOnce( + Object.assign(new Error('Ledger not connected'), { code: 'LEDGER_NOT_CONNECTED' }) + ); + + await expect(signSafePending(5, 2)).rejects.toMatchObject({ code: 'LEDGER_NOT_CONNECTED' }); + expect(getPending(5).signatures).toHaveLength(1); // owner 0's survives + expect(getSafeSendState(5).status).toBe('awaiting'); // guard released + }); + + test('refuses concurrent steps (SAFE_BUSY) and exposes the signing status', async () => { + await start(); + let resolveSign; + mockCollectOwnerSignature.mockImplementationOnce( + () => new Promise((resolve) => (resolveSign = resolve)) + ); + + const inFlight = signSafePending(5, 2); + await new Promise((resolve) => setImmediate(resolve)); + + await expect(signSafePending(5, 4)).rejects.toMatchObject({ code: 'SAFE_BUSY' }); + expect(() => cancelSafeSend(5)).toThrow(/current step/i); + + resolveSign(signatureOf(2)); + await inFlight; + expect(getSafeSendState(5).status).toBe('awaiting'); + }); + + test('drops a signature whose transaction was replaced mid-ceremony', async () => { + await start(); + mockCollectOwnerSignature.mockImplementationOnce(async ({ ownerIndex }) => { + // While the device ceremony runs, the pending entry disappears + // (simulates a discard path that bypassed the guard). + clearPending(5); + return signatureOf(ownerIndex); + }); + + await expect(signSafePending(5, 2)).rejects.toMatchObject({ code: 'SAFE_DISCARDED' }); + expect(getPending(5)).toBeNull(); + }); +}); + +describe('executeSafePending', () => { + async function readyPending() { + await start(); + await signSafePending(5, 2); + } + + test('executes at threshold with recording, clears pending, returns the result', async () => { + await readyPending(); + + const state = await executeSafePending(5); + + expect(mockExecTransaction).toHaveBeenCalledWith({ + chainId: 100, + safeAddress: SAFE_ADDRESS, + safeTxData: builtResult.safeTxData, + signatures: [signatureOf(0), signatureOf(2)], + executorIndex: 0, + record: { + kind: 'safe-send', + fromAddress: SAFE_ADDRESS, + toAddress: DISPLAY.toAddress, + asset: null, + amount: '1000', + metadata: { safeAddress: SAFE_ADDRESS, safeTxHash: SAFE_TX_HASH }, + }, + }); + expect(state.status).toBe('executed'); + expect(state.executed).toEqual({ hash: TX_HASH, explorerUrl: `https://gnosisscan.io/tx/${TX_HASH}` }); + expect(getPending(5)).toBeNull(); + expect(getSafeSendState(5)).toBeNull(); + }); + + test('refuses below the threshold', async () => { + await start(); // 1 of 2 + await expect(executeSafePending(5)).rejects.toThrow(/not enough signatures/i); + expect(mockExecTransaction).not.toHaveBeenCalled(); + }); + + test('a moved Safe nonce flips the transaction to a terminal superseded state', async () => { + await readyPending(); + mockRpcRequest.mockResolvedValue('0x4'); // chain nonce > SafeTx nonce 3 + + const state = await executeSafePending(5); + + expect(state.status).toBe('superseded'); + expect(mockExecTransaction).not.toHaveBeenCalled(); + // persisted: the board renders it terminal without another chain read + expect(getSafeSendState(5).status).toBe('superseded'); + await expect(signSafePending(5, 4)).rejects.toThrow(/discard/i); + // discard is the way out + cancelSafeSend(5); + expect(getSafeSendState(5)).toBeNull(); + }); + + test("maps the executor's empty wallet to SAFE_NEEDS_FUNDS, signatures intact", async () => { + await readyPending(); + mockExecTransaction.mockRejectedValueOnce(new Error('Insufficient funds for transaction')); + + await expect(executeSafePending(5)).rejects.toMatchObject({ code: 'SAFE_NEEDS_FUNDS' }); + expect(getPending(5).signatures).toHaveLength(2); + }); + + test('a failed broadcast keeps everything for a retry', async () => { + await readyPending(); + mockExecTransaction.mockRejectedValueOnce(new Error('RPC down')); + + await expect(executeSafePending(5)).rejects.toThrow('RPC down'); + expect(getPending(5).signatures).toHaveLength(2); + + const state = await executeSafePending(5); // retry, no re-signing + expect(state.status).toBe('executed'); + expect(mockCollectOwnerSignature).toHaveBeenCalledTimes(2); // from setup only + }); +}); + +describe('getSafeSendState / cancelSafeSend', () => { + test('null when nothing is pending; cancel clears', async () => { + expect(getSafeSendState(5)).toBeNull(); + await start(); + expect(getSafeSendState(5)).not.toBeNull(); + cancelSafeSend(5); + expect(getSafeSendState(5)).toBeNull(); + }); +}); + +describe('getAllSafeSendStates', () => { + test('lists every pending SafeTx, oldest first', async () => { + expect(getAllSafeSendStates()).toEqual([]); + await start(); + const states = getAllSafeSendStates(); + expect(states).toHaveLength(1); + expect(states[0]).toMatchObject({ safeIndex: 5, collected: 1, threshold: 2 }); + }); +}); diff --git a/src/main/wallet/safe/signature-collection.js b/src/main/wallet/safe/signature-collection.js new file mode 100644 index 00000000..c3461e3a --- /dev/null +++ b/src/main/wallet/safe/signature-collection.js @@ -0,0 +1,146 @@ +/** + * Owner-signature collection shared by the Safe pending flows — SafeTx + * sends (safe-transactions.js, persisted) and SafeMessage sessions + * (safe-messages.js, in-memory). Both collect owner signatures over an + * EIP-712 payload at the user's pace, so the machinery is identical; + * only the store differs. + * + * A store adapter is `{get(safeIndex), set(safeIndex, entry), idOf(entry)}` + * over entries shaped `{typedData, threshold, signatures, superseded?}`. + * `idOf` names the entry's identity (SafeTx hash / SafeMessage hash) so a + * signature landing after a discard-and-replace is dropped, never + * attached to the wrong payload. + */ + +const { collectOwnerSignature } = require('./safe-executor'); +const { getWalletRecord, isVaultUnlocked, WALLET_TYPES } = require('../../identity-manager'); +const { codedError, SAFE_BUSY, SAFE_DISCARDED } = require('./errors'); + +// One live ceremony/execution per Safe at a time, across sends AND +// message sessions — a device can't serve two ceremonies at once. +// In-memory only (a main-process crash clears it, the correct reset); +// the renderer keeps its own row-level spinner state. +const inFlight = new Set(); + +function acquire(safeIndex) { + if (inFlight.has(safeIndex)) { + throw codedError('Another step of this transaction is still running', SAFE_BUSY); + } + inFlight.add(safeIndex); +} + +function release(safeIndex) { + inFlight.delete(safeIndex); +} + +function isBusy(safeIndex) { + return inFlight.has(safeIndex); +} + +const hasSigned = (signatures, address) => + Boolean(address) && + signatures.some((sig) => sig.signer.toLowerCase() === address.toLowerCase()); + +/** The owner rows of the render model, derived from live wallet records. */ +function ownersView(ownerIndexes, signatures) { + return ownerIndexes.map((index) => { + const record = getWalletRecord(index); + return { + index, + address: record?.address || null, + type: record?.type || WALLET_TYPES.MNEMONIC, + signed: hasSigned(signatures, record?.address), + }; + }); +} + +/** + * Silently sign every unsigned mnemonic owner while the vault is + * unlocked — zero ceremony, so no user action is required. The single + * home of the "which signatures are free" policy; runs at creation and + * again when the board (re)opens (covers a vault that was locked the + * first time). + */ +async function collectFreeSignatures(store, safeIndex, ownerIndexes) { + if (!(await isVaultUnlocked())) { + return; + } + for (const ownerIndex of ownerIndexes) { + const entry = store.get(safeIndex); + if (!entry || entry.superseded || entry.signatures.length >= entry.threshold) break; + const owner = getWalletRecord(ownerIndex); + if (owner?.type !== WALLET_TYPES.MNEMONIC || hasSigned(entry.signatures, owner.address)) { + continue; + } + try { + const signature = await collectOwnerSignature({ typedData: entry.typedData, ownerIndex }); + entry.signatures = [...entry.signatures, signature]; + store.set(safeIndex, entry); + } catch (err) { + // Vault locked mid-loop or a derivation hiccup: this owner + // degrades to a manual row on the board, nothing fails. + console.warn(`[SafeSign] auto-sign skipped for owner ${ownerIndex}:`, err.message); + } + } +} + +/** + * Collect one owner's signature (the user tapped that row, the device is + * in their hand) — or, called without an ownerIndex, sweep the free + * signatures instead. Persists through the store immediately; errors + * (device not connected, rejection, locked vault) leave the entry + * intact — they belong to the row, not the pending item. + * + * Callers own the existence/terminal-state checks on the entry; this + * owns membership, idempotency, mutual exclusion, and the + * identity re-check after a ceremony that may have taken minutes. + */ +async function signEntryOwner({ store, safeIndex, ownerIndex, ownerIndexes }) { + if (ownerIndex == null) { + acquire(safeIndex); + try { + await collectFreeSignatures(store, safeIndex, ownerIndexes); + } finally { + release(safeIndex); + } + return; + } + + if (!ownerIndexes.includes(ownerIndex)) { + throw new Error('That account is not an owner of this Safe'); + } + const entry = store.get(safeIndex); + if (hasSigned(entry.signatures, getWalletRecord(ownerIndex)?.address)) { + return; // already signed — idempotent + } + + acquire(safeIndex); + try { + const expectedId = store.idOf(entry); + const signature = await collectOwnerSignature({ typedData: entry.typedData, ownerIndex }); + + // The ceremony can take minutes: re-validate that THIS payload is + // still the pending one before persisting into it. + const current = store.get(safeIndex); + if (!current || store.idOf(current) !== expectedId) { + throw codedError( + 'The request was discarded while the signature was being made', + SAFE_DISCARDED + ); + } + current.signatures = [...current.signatures, signature]; + store.set(safeIndex, current); + } finally { + release(safeIndex); + } +} + +module.exports = { + acquire, + release, + isBusy, + hasSigned, + ownersView, + collectFreeSignatures, + signEntryOwner, +}; diff --git a/src/main/wallet/signers.js b/src/main/wallet/signers.js index 0c1d5192..bb268145 100644 --- a/src/main/wallet/signers.js +++ b/src/main/wallet/signers.js @@ -25,7 +25,7 @@ const { withVaultPrivateKey, isValidWalletIndex } = require('./vault-access'); const { getWalletRecord, WALLET_TYPES } = require('../identity-manager'); const { createLedgerBackend } = require('./ledger/signer'); const { createRemoteBackend } = require('./remote/signer'); -const { withoutDomainType } = require('./signing-utils'); +const { normalizeMessage, normalizeTypedData, withoutDomainType } = require('./signing-utils'); /** * @typedef {Object} Signer @@ -46,19 +46,6 @@ const { withoutDomainType } = require('./signing-utils'); * only); callers must prefer it over signTransaction+broadcast. */ -/** 0x-hex dApp messages are signatures over the bytes, not the hex text. */ -function normalizeMessage(message) { - if (typeof message === 'string' && message.startsWith('0x')) { - return Buffer.from(message.slice(2), 'hex'); - } - return message; -} - -/** dApps send typed data either as an object or a JSON string. */ -function normalizeTypedData(typedData) { - return typeof typedData === 'string' ? JSON.parse(typedData) : typedData; -} - function createVaultBackend(walletIndex) { return { getAddress: () => withVaultPrivateKey(walletIndex, (privateKey) => computeAddress(privateKey)), @@ -107,6 +94,12 @@ function getSigner(walletIndex) { // Unknown indexes fall through to the vault backend, which fails with // its own vault-derivation errors — the pre-hardware-wallet behaviour. const record = getWalletRecord(walletIndex); + if (record && record.type === WALLET_TYPES.SAFE) { + // A Safe is a smart-contract account whose owners are other wallet + // records — it has no key of its own. Execution goes through the + // SafeExecutor (./safe/), which signs with the owners' signers. + throw new Error('Safe accounts cannot sign directly — execute through the Safe flow'); + } let backend; if (record && record.type === WALLET_TYPES.LEDGER) { backend = createLedgerBackend(record); diff --git a/src/main/wallet/signers.test.js b/src/main/wallet/signers.test.js index bb0ad0bd..0edbb33e 100644 --- a/src/main/wallet/signers.test.js +++ b/src/main/wallet/signers.test.js @@ -30,7 +30,7 @@ const mockCreateRemoteBackend = jest.fn(() => mockRemoteBackend); jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), getWalletRecord: (...args) => mockGetWalletRecord(...args), - WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote' }, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote', SAFE: 'safe' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, @@ -201,6 +201,19 @@ describe('getSigner (ledger-backed dispatch)', () => { }); }); +describe('getSigner (safe records)', () => { + test('throws: a Safe is an account, not a signer', () => { + mockGetWalletRecord.mockReturnValue({ + index: 5, + name: 'Joint account', + address: '0x41aD4887971f90BB3fE4d83eCa65177281283261', + type: 'safe', + }); + expect(() => getSigner(5)).toThrow(/Safe account.*cannot sign directly/i); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); +}); + describe('getSigner (remote-backed dispatch)', () => { const REMOTE_RECORD = { index: 4, diff --git a/src/main/wallet/signing-utils.js b/src/main/wallet/signing-utils.js index 5a611a24..522fa86d 100644 --- a/src/main/wallet/signing-utils.js +++ b/src/main/wallet/signing-utils.js @@ -10,6 +10,19 @@ const { TypedDataEncoder } = require('ethers'); +/** 0x-hex dApp messages are signatures over the bytes, not the hex text. */ +function normalizeMessage(message) { + if (typeof message === 'string' && message.startsWith('0x')) { + return Buffer.from(message.slice(2), 'hex'); + } + return message; +} + +/** dApps send typed data either as an object or a JSON string. */ +function normalizeTypedData(typedData) { + return typeof typedData === 'string' ? JSON.parse(typedData) : typedData; +} + /** Types with EIP712Domain stripped, as ethers' hashing helpers expect. */ function withoutDomainType(types) { const stripped = { ...types }; @@ -37,4 +50,10 @@ function messageToBytes(message) { return Buffer.isBuffer(message) ? message : Buffer.from(String(message), 'utf8'); } -module.exports = { withoutDomainType, getEip712WirePayload, messageToBytes }; +module.exports = { + normalizeMessage, + normalizeTypedData, + withoutDomainType, + getEip712WirePayload, + messageToBytes, +}; diff --git a/src/main/wallet/transaction-service.js b/src/main/wallet/transaction-service.js index 8283e094..474ced42 100644 --- a/src/main/wallet/transaction-service.js +++ b/src/main/wallet/transaction-service.js @@ -105,6 +105,21 @@ async function getGasPrices(chainId) { } } +/** + * getGasPrices result → the fee fields buildTransaction / send params + * expect, so callers don't re-derive the eip1559-vs-legacy branch. + * @param {Object} gasPrices - Result of getGasPrices + * @returns {{maxFeePerGas: string, maxPriorityFeePerGas: string}|{gasPrice: string}} + */ +function toFeeFields(gasPrices) { + return gasPrices.type === 'eip1559' + ? { + maxFeePerGas: gasPrices.maxFeePerGas, + maxPriorityFeePerGas: gasPrices.maxPriorityFeePerGas, + } + : { gasPrice: gasPrices.gasPrice }; +} + /** * Build ERC-20 transfer calldata * @param {string} to - Recipient address @@ -398,6 +413,7 @@ async function waitForTransaction(txHash, chainId, confirmations = 1) { module.exports = { estimateGas, getGasPrices, + toFeeFields, buildErc20TransferData, parseAmount, formatAmount, diff --git a/src/main/wallet/tx-recorder.js b/src/main/wallet/tx-recorder.js index 9c1f9c18..9ae26745 100644 --- a/src/main/wallet/tx-recorder.js +++ b/src/main/wallet/tx-recorder.js @@ -24,6 +24,10 @@ function toAtomicDecimal(value) { * @param {object} context * @param {string} context.kind paymentHistory.KINDS member * @param {string} [context.origin] normalised origin (dapp sends only) + * @param {string} [context.fromAddress] human-visible sender; defaults to the + * signer's address (which is the *executor* + * for Safe txs, not the paying account — + * pass the safe address explicitly there) * @param {string} [context.asset] ERC-20 contract address (null = native) * @param {string} [context.amount] atomic units; defaults to params.value * (which is 0 for ERC-20 transfers — pass @@ -45,7 +49,7 @@ async function signAndRecord(params, signer, context) { kind: context.kind, chainId: params.chainId, txHash: response.hash, - fromAddress: response.from, + fromAddress: context.fromAddress ?? response.from, toAddress: context.toAddress ?? params.to, asset: context.asset ?? null, amount: toAtomicDecimal(context.amount ?? params.value), diff --git a/src/main/wallet/tx-recorder.test.js b/src/main/wallet/tx-recorder.test.js index 5277be65..b364dfef 100644 --- a/src/main/wallet/tx-recorder.test.js +++ b/src/main/wallet/tx-recorder.test.js @@ -67,6 +67,27 @@ describe('tx-recorder', () => { })); }); + test('context.fromAddress overrides the signer address (Safe txs: from = safe, executor in metadata)', async () => { + await signAndRecord({ + to: '0xsafe', + value: '0', + chainId: 100, + }, fakeSigner, { + kind: 'safe-send', + fromAddress: '0xsafe', + toAddress: '0xrecipient', + amount: '1000', + metadata: { safeAddress: '0xsafe', executor: '0xfrom' }, + }); + + expect(mockAppend).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'safe-send', + fromAddress: '0xsafe', + toAddress: '0xrecipient', + metadata: { safeAddress: '0xsafe', executor: '0xfrom' }, + })); + }); + test('surfaces recorded:false when the broadcast succeeds but history append fails', async () => { mockAppend.mockImplementationOnce(() => { throw new Error('db closed'); diff --git a/src/main/wallet/wallet-ipc.js b/src/main/wallet/wallet-ipc.js index 87980bf0..667b829b 100644 --- a/src/main/wallet/wallet-ipc.js +++ b/src/main/wallet/wallet-ipc.js @@ -21,6 +21,7 @@ const { signAndRecord, KINDS: PAYMENT_KINDS } = require('./tx-recorder'); const { getActiveWalletIndex } = require('../identity-manager'); const { getEffectiveRpcUrls } = require('./rpc-manager'); const { getSigner } = require('./signers'); +const { isVaultLockedError } = require('./vault-errors'); /** * Validate that an RPC URL is a known, trusted endpoint. @@ -303,6 +304,152 @@ function registerWalletIpc() { } }); + // Safe account lifecycle (chain-touching — see safe/safe-service.js). + // Lazily required so wallet-ipc doesn't load protocol-kit at startup. + ipcMain.handle('wallet:create-safe', async (_event, name, ownerIndexes, threshold) => { + try { + const { createSafeAccount } = require('./safe/safe-service'); + const wallet = await createSafeAccount({ name, ownerIndexes, threshold }); + return { success: true, wallet }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle('wallet:get-safe-status', async (_event, index) => { + try { + const { getSafeStatus } = require('./safe/safe-service'); + const status = await getSafeStatus(index); + return { success: true, status }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle('wallet:activate-safe', async (_event, index) => { + try { + const { activateSafe } = require('./safe/safe-service'); + const result = await activateSafe(index); + return { success: true, ...result }; + } catch (err) { + const code = err.code ?? (isVaultLockedError(err) ? 'VAULT_LOCKED' : undefined); + return { success: false, error: err.message, code }; + } + }); + + // Safe sends — the signing board's granular API: build (+ silent free + // signatures), sign one owner per user action, execute as its own + // idempotent step, render from state. Half-signed transactions are + // persisted main-side; signature failures never destroy them. + const safeStateHandler = (fn) => async (_event, ...args) => { + try { + const state = await fn(...args); + return { success: true, state }; + } catch (err) { + // A locked vault is recoverable — the renderer walks the user + // through the standard unlock and retries. + const code = err.code ?? (isVaultLockedError(err) ? 'VAULT_LOCKED' : undefined); + return { success: false, error: err.message, code }; + } + }; + + ipcMain.handle( + 'wallet:safe-send', + safeStateHandler((safeIndex, tx, display) => { + const { startSafeSend } = require('./safe/safe-transactions'); + return startSafeSend({ safeIndex, tx, display }); + }) + ); + + ipcMain.handle( + 'wallet:safe-sign', + safeStateHandler((safeIndex, ownerIndex) => { + const { signSafePending } = require('./safe/safe-transactions'); + return signSafePending(safeIndex, ownerIndex); + }) + ); + + ipcMain.handle( + 'wallet:safe-execute', + safeStateHandler((safeIndex) => { + const { executeSafePending } = require('./safe/safe-transactions'); + return executeSafePending(safeIndex); + }) + ); + + ipcMain.handle( + 'wallet:safe-state', + safeStateHandler((safeIndex) => { + const { getSafeSendState } = require('./safe/safe-transactions'); + return getSafeSendState(safeIndex); + }) + ); + + ipcMain.handle( + 'wallet:safe-cancel-pending', + safeStateHandler((safeIndex) => { + const { cancelSafeSend } = require('./safe/safe-transactions'); + cancelSafeSend(safeIndex); + }) + ); + + ipcMain.handle('wallet:safe-pending-list', async () => { + try { + const { getAllSafeSendStates } = require('./safe/safe-transactions'); + return { success: true, states: getAllSafeSendStates() }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + // SafeMessage sessions — dApp message signing via EIP-1271 (see + // safe/safe-messages.js). Same granular board API as sends; complete + // returns the concatenated owner signatures instead of a state. Each + // session is bound to its requesting page: start takes the requester + // identity and returns a per-session token that every other call must + // present. + ipcMain.handle( + 'wallet:safe-message-start', + safeStateHandler((safeIndex, request, display, requester) => { + const { startSafeMessage } = require('./safe/safe-messages'); + return startSafeMessage({ safeIndex, request, display, requester }); + }) + ); + + ipcMain.handle( + 'wallet:safe-message-sign', + safeStateHandler((safeIndex, ownerIndex, token) => { + const { signSafeMessage } = require('./safe/safe-messages'); + return signSafeMessage(safeIndex, ownerIndex, token); + }) + ); + + ipcMain.handle( + 'wallet:safe-message-state', + safeStateHandler((safeIndex, token) => { + const { getSafeMessageState } = require('./safe/safe-messages'); + return getSafeMessageState(safeIndex, token); + }) + ); + + ipcMain.handle( + 'wallet:safe-message-cancel', + safeStateHandler((safeIndex, token) => { + const { cancelSafeMessage } = require('./safe/safe-messages'); + cancelSafeMessage(safeIndex, token); + }) + ); + + ipcMain.handle('wallet:safe-message-complete', async (_event, safeIndex, token) => { + try { + const { completeSafeMessage } = require('./safe/safe-messages'); + const { signature } = completeSafeMessage(safeIndex, token); + return { success: true, signature }; + } catch (err) { + return { success: false, error: err.message, code: err.code }; + } + }); + // Proxy JSON-RPC calls to external endpoints (renderer CSP blocks direct fetch) ipcMain.handle('wallet:proxy-rpc', async (_event, { rpcUrl, method, params }) => { try { diff --git a/src/renderer/index.html b/src/renderer/index.html index 717fcea6..bfe34ccf 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -851,6 +851,12 @@

Set Up Your Identity

Connect Phone / Other Device + @@ -876,6 +882,9 @@

Set Up Your Identity

+ + +
@@ -883,6 +892,18 @@

Set Up Your Identity

+ + +
@@ -1412,6 +1433,115 @@

Phone Account Added!

+ + + + + + + + + + + +
This will allow the site to:
@@ -1939,6 +2079,16 @@

Sign Message

Only sign messages from sites you trust. Signing can authorize actions on your behalf.
+ + +