diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index 05464abee..11deb6b9e 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -65,6 +65,7 @@ import { getProxyFactoryContract, getSafeContract } from './contracts/safeDeploymentContracts' +import SafeMessage from './utils/messages/SafeMessage' class Safe { #predictedSafe?: PredictedSafeProps @@ -529,6 +530,55 @@ class Safe { return hashSafeMessage(message) } + createMessage(message: string | EIP712TypedData): SafeMessage { + return new SafeMessage(message) + } + + async signMessage( + message: SafeMessage, + signingMethod = 'eth_signTypedData_v4', + isSmartContract = false + ): Promise { + const owners = await this.getOwners() + const signerAddress = await this.#ethAdapter.getSignerAddress() + if (!signerAddress) { + throw new Error('EthAdapter must be initialized with a signer to use this method') + } + const addressIsOwner = owners.some( + (owner: string) => signerAddress && sameString(owner, signerAddress) + ) + if (!addressIsOwner) { + throw new Error('Transactions can only be signed by Safe owners') + } + + let signature: SafeSignature + if (signingMethod === 'eth_signTypedData_v4') { + signature = await this.signTypedData(message, 'v4', isSmartContract) + } else if (signingMethod === 'eth_signTypedData_v3') { + signature = await this.signTypedData(message, 'v3', isSmartContract) + } else if (signingMethod === 'eth_signTypedData') { + signature = await this.signTypedData(message, undefined, isSmartContract) + } else { + const safeVersion = await this.getContractVersion() + if (!hasSafeFeature(SAFE_FEATURES.ETH_SIGN, safeVersion)) { + throw new Error('eth_sign is only supported by Safes >= v1.1.0') + } + + const safeMessageHash = await this.getSafeMessageHash(this.hashSafeMessage(message.data)) + signature = await this.signHash(safeMessageHash, isSmartContract) + } + + const signedSafeMessage = this.createMessage(message.data) + + message.signatures.forEach((signature: EthSafeSignature) => { + signedSafeMessage.addSignature(signature) + }) + + signedSafeMessage.addSignature(signature) + + return signedSafeMessage + } + /** * Signs a transaction according to the EIP-712 using the current signer account. * @@ -537,7 +587,7 @@ class Safe { * @returns The Safe signature */ async signTypedData( - eip712Data: SafeTransaction | EIP712TypedData | string, + eip712Data: SafeTransaction | SafeMessage, methodVersion?: 'v3' | 'v4', isSmartContract = false ): Promise { diff --git a/packages/protocol-kit/src/utils/messages/SafeMessage.ts b/packages/protocol-kit/src/utils/messages/SafeMessage.ts new file mode 100644 index 000000000..8b458f23e --- /dev/null +++ b/packages/protocol-kit/src/utils/messages/SafeMessage.ts @@ -0,0 +1,21 @@ +import { EIP712TypedData, SafeMessage, SafeSignature } from '@safe-global/safe-core-sdk-types' +import { buildSignature } from '../signatures' + +class EthSafeMessage implements SafeMessage { + data: EIP712TypedData | string + signatures: Map = new Map() + + constructor(data: EIP712TypedData | string) { + this.data = data + } + + addSignature(signature: SafeSignature): void { + this.signatures.set(signature.signer.toLowerCase(), signature) + } + + encodedSignatures(): string { + return buildSignature(Array.from(this.signatures.values())) + } +} + +export default EthSafeMessage diff --git a/packages/protocol-kit/tests/e2e/eip1271.test.ts b/packages/protocol-kit/tests/e2e/eip1271.test.ts index db1249c55..c5296ca90 100644 --- a/packages/protocol-kit/tests/e2e/eip1271.test.ts +++ b/packages/protocol-kit/tests/e2e/eip1271.test.ts @@ -1,3 +1,4 @@ +import { ethers } from 'ethers' import Safe from '@safe-global/protocol-kit/index' import { safeVersionDeployed } from '@safe-global/protocol-kit/hardhat/deploy/deploy-contracts' import { @@ -14,8 +15,8 @@ import { getEthAdapter } from './utils/setupEthAdapter' import { getAccounts } from './utils/setupTestNetwork' import { waitSafeTxReceipt } from './utils/transactions' import { itif } from './utils/helpers' -import { ethers } from 'ethers' -import { buildSignature, hashSafeMessage } from '@safe-global/protocol-kit/utils' +import { buildSignature } from '@safe-global/protocol-kit/utils' +import SafeMessage from '../../src/utils/messages/SafeMessage' chai.use(chaiAsPromised) @@ -165,109 +166,206 @@ describe.only('EIP1271', () => { } ) - itif(safeVersionDeployed >= '1.3.0')('should validate off-chain messages', async () => { - const { safeSdk1, safeSdk2 } = await setupTests() + itif(safeVersionDeployed >= '1.3.0')('should revert when message is not signed', async () => { + const { safeSdk1 } = await setupTests() - // Hash the message - const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) - const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + const response = await safeSdk1.isValidSignature( + await safeSdk1.hashSafeMessage(MESSAGE), + '0x' + ) - // Sign the Safe message hash with the owners - const ethSignSig1 = await safeSdk1.signHash(safeMessageHash) - const ethSignSig2 = await safeSdk2.signHash(safeMessageHash) + chai.expect(response).to.be.false + }) - // Validate the signature sending the Safe message hash and the concatenated signatures - const isValid1 = await safeSdk1.isValidSignature( - messageHash, - buildSignature([ethSignSig1, ethSignSig2]) - ) + describe('getSafeMessageHash(), signHash(), signTypedData()', () => { + itif(safeVersionDeployed >= '1.3.0')( + 'should allow to validate off-chain messages', + async () => { + const { safeSdk1, safeSdk2 } = await setupTests() - chai.expect(isValid1).to.be.true + // Hash the message + const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) - // Validate the signature sending the Safe message hash and the array of SafeSignature - const isValid2 = await safeSdk1.isValidSignature(messageHash, [ethSignSig1, ethSignSig2]) + // Sign the Safe message hash with the owners + const ethSignSig1 = await safeSdk1.signHash(safeMessageHash) + const ethSignSig2 = await safeSdk2.signHash(safeMessageHash) - chai.expect(isValid2).to.be.true + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid1 = await safeSdk1.isValidSignature( + messageHash, + buildSignature([ethSignSig1, ethSignSig2]) + ) - // Validate the signature is not valid when not enough signers has signed - const isValid3 = await safeSdk1.isValidSignature(messageHash, [ethSignSig1]) + chai.expect(isValid1).to.be.true - chai.expect(isValid3).to.be.false - }) + // Validate the signature sending the Safe message hash and the array of SafeSignature + const isValid2 = await safeSdk1.isValidSignature(messageHash, [ethSignSig1, ethSignSig2]) - itif(safeVersionDeployed >= '1.3.0')( - 'should validate a mix EIP191 and EIP712 signatures', - async () => { - const { safeSdk1, safeSdk2 } = await setupTests() + chai.expect(isValid2).to.be.true - // Hash the message - const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) - const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + // Validate the signature is not valid when not enough signers has signed + const isValid3 = await safeSdk1.isValidSignature(messageHash, [ethSignSig1]) - // Sign the Safe message with the owners - const ethSignSig = await safeSdk1.signHash(safeMessageHash) + chai.expect(isValid3).to.be.false + } + ) - const typedDataSig = await safeSdk2.signTypedData(MESSAGE) - // Validate the signature sending the Safe message hash and the concatenated signatures - const isValid = await safeSdk1.isValidSignature(messageHash, [typedDataSig, ethSignSig]) + itif(safeVersionDeployed >= '1.3.0')( + 'should validate a mix EIP191 and EIP712 signatures', + async () => { + const { safeSdk1, safeSdk2 } = await setupTests() - chai.expect(isValid).to.be.true - } - ) + // Hash the message + const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) - itif(safeVersionDeployed >= '1.3.0')( - 'should validate Smart contracts as signers (threshold = 1)', - async () => { - const { safeSdk1, safeSdk2, safeSdk3 } = await setupTests() + // Sign the Safe message with the owners + const ethSignSig = await safeSdk1.signHash(safeMessageHash) - // Hash the message - const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) - const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + const typedDataSig = await safeSdk2.signTypedData(MESSAGE) + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid = await safeSdk1.isValidSignature(messageHash, [typedDataSig, ethSignSig]) - // Sign the Safe message with the owners - const ethSignSig = await safeSdk1.signHash(safeMessageHash) - const typedDataSig = await safeSdk2.signTypedData(messageHash) + chai.expect(isValid).to.be.true + } + ) - // Sign with the Smart contract - const safeSignerMessageHash = await safeSdk3.getSafeMessageHash(messageHash) - const signerSafeSig = await safeSdk3.signHash(safeSignerMessageHash, true) + itif(safeVersionDeployed >= '1.3.0')( + 'should validate Smart contracts as signers (threshold = 1)', + async () => { + const { safeSdk1, safeSdk2, safeSdk3 } = await setupTests() - // Validate the signature sending the Safe message hash and the concatenated signatures - const isValid = await safeSdk1.isValidSignature(messageHash, [ - signerSafeSig, - ethSignSig, - typedDataSig - ]) + // Hash the message + const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) - chai.expect(isValid).to.be.true - } - ) + // Sign the Safe message with the owners + const ethSignSig = await safeSdk1.signHash(safeMessageHash) + const typedDataSig = await safeSdk2.signTypedData(messageHash) - itif(safeVersionDeployed >= '1.3.0')('should revert when message is not signed', async () => { - const { safeSdk1 } = await setupTests() + // Sign with the Smart contract + const safeSignerMessageHash = await safeSdk3.getSafeMessageHash(messageHash) + const signerSafeSig = await safeSdk3.signHash(safeSignerMessageHash, true) - const response = await safeSdk1.isValidSignature( - await safeSdk1.hashSafeMessage(MESSAGE), - '0x' + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid = await safeSdk1.isValidSignature(messageHash, [ + signerSafeSig, + ethSignSig, + typedDataSig + ]) + + chai.expect(isValid).to.be.true + } ) - chai.expect(response).to.be.false + itif(safeVersionDeployed >= '1.3.0')( + 'should generate the correct safeMessageHash', + async () => { + const { safeAddress, safeSdk1 } = await setupTests() + + const chainId = await safeSdk1.getChainId() + const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + chai + .expect(safeMessageHash) + .to.be.eq(calculateSafeMessageHash(safeAddress, messageHash, chainId)) + } + ) }) - itif(safeVersionDeployed >= '1.3.0')( - 'should generate the correct safeMessageHash', - async () => { - const { safeAddress, safeSdk1 } = await setupTests() + describe('signMessage()', () => { + itif(safeVersionDeployed >= '1.3.0')('should validate off-chain messages', async () => { + const { safeSdk1, safeSdk2 } = await setupTests() - const chainId = await safeSdk1.getChainId() - const messageHash = await safeSdk1.hashSafeMessage(MESSAGE) - const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + // EIP191 sign the Safe message with owners + const safeMessage = safeSdk1.createMessage(MESSAGE) + + const signedMessage1: SafeMessage = await safeSdk1.signMessage(safeMessage, 'eth_sign') + const signedMessage2: SafeMessage = await safeSdk2.signMessage(signedMessage1, 'eth_sign') + + // Validate the signature + chai.expect( + await safeSdk1.isValidSignature( + safeSdk1.hashSafeMessage(MESSAGE), + signedMessage2.encodedSignatures() + ) + ).to.be.true + + // Validate the signature is not valid when not enough signers has signed + chai.expect( + await safeSdk1.isValidSignature( + safeSdk1.hashSafeMessage(MESSAGE), + signedMessage1.encodedSignatures() + ) + ).to.be.false + }) - chai - .expect(safeMessageHash) - .to.be.eq(calculateSafeMessageHash(safeAddress, messageHash, chainId)) - } - ) + itif(safeVersionDeployed >= '1.3.0')( + 'should validate a mix EIP191 and EIP712 signatures', + async () => { + const { safeSdk1, safeSdk2 } = await setupTests() + + // EIP191 and EIP712 sign the Safe message with owners + const safeMessage = safeSdk1.createMessage(MESSAGE) + + const signedMessage1: SafeMessage = await safeSdk1.signMessage(safeMessage, 'eth_sign') + const signedMessage2: SafeMessage = await safeSdk2.signMessage( + signedMessage1, + 'eth_signTypedData_v4' + ) + + // Validate the signature + chai.expect( + await safeSdk1.isValidSignature( + safeSdk1.hashSafeMessage(MESSAGE), + signedMessage2.encodedSignatures() + ) + ).to.be.true + } + ) + + itif(safeVersionDeployed >= '1.3.0')( + 'should validate Smart contracts as signers (threshold = 1)', + async () => { + const { safeSdk1, safeSdk3 } = await setupTests() + + // Sign the Safe message with owners + const safeMessage = safeSdk1.createMessage(MESSAGE) + + const signedMessage1: SafeMessage = await safeSdk1.signMessage(safeMessage, 'eth_sign') + const signedMessage2: SafeMessage = await safeSdk3.signMessage( + signedMessage1, + 'eth_sign', + true + ) + + // Validate the signature + chai.expect( + await safeSdk1.isValidSignature( + safeSdk1.hashSafeMessage(MESSAGE), + signedMessage2.encodedSignatures() + ) + ).to.be.true + } + ) + + itif(safeVersionDeployed >= '1.3.0')( + 'should generate the correct safeMessageHash', + async () => { + const { safeAddress, safeSdk1 } = await setupTests() + + const chainId = await safeSdk1.getChainId() + const messageHash = safeSdk1.hashSafeMessage(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + chai + .expect(safeMessageHash) + .to.be.eq(calculateSafeMessageHash(safeAddress, messageHash, chainId)) + } + ) + }) it.skip('should allow use to sign transactions using Safe Accounts (threshold = 1)', async () => { const { signerSafeAddress, safeAddress, accounts, safeSdk1, safeSdk2, safeSdk3, signerSafe } = diff --git a/packages/safe-core-sdk-types/src/types.ts b/packages/safe-core-sdk-types/src/types.ts index c52e0708d..3764c0286 100644 --- a/packages/safe-core-sdk-types/src/types.ts +++ b/packages/safe-core-sdk-types/src/types.ts @@ -59,6 +59,13 @@ export interface SafeTransaction { encodedSignatures(): string } +export interface SafeMessage { + readonly data: EIP712TypedData | string + readonly signatures: Map + addSignature(signature: SafeSignature): void + encodedSignatures(): string +} + export type Transaction = TransactionBase & TransactionOptions interface TransactionBase {