Skip to content

Commit fc18871

Browse files
feat: enable arbitrary data signing (#3720)
* allow passing of Uint8Array to the `hashMessage` function * allow passing of Uint8Array to the `signMessage` function * finalized all change for using sha256 * chore: favour `keccak256` * updated snippet * docs: updated signing docs * chore: changeset * breaking chnage * add EIP * changeset * backward compatibility changes * changeset * changeset * fix docs hash * lintfix * updated docs * Using `personalSign` as EIP-191 key --------- Co-authored-by: Anderson Arboleya <anderson@arboleya.me>
1 parent 853faf7 commit fc18871

File tree

13 files changed

+201
-33
lines changed

13 files changed

+201
-33
lines changed

.changeset/four-sheep-love.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@fuel-ts/account": patch
3+
"@fuel-ts/hasher": patch
4+
---
5+
6+
feat: enable arbitrary data signing

apps/docs/spell-check-custom-words.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,5 @@ WSL
344344
XOR
345345
XORs
346346
YAML
347-
RESTful
347+
RESTful
348+
EIP

apps/docs/src/guide/wallets/signing.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,39 @@
22

33
## Signing Messages
44

5-
Signing messages with a wallet is a fundamental security practice in a blockchain environment. It verifies ownership and ensures the integrity of data. Here's how to use the `wallet.signMessage` method to sign messages:
5+
Signing messages with a wallet is a fundamental security practice in a blockchain environment. It can be used to verify ownership and ensure the integrity of data.
6+
7+
Here's how to use the `wallet.signMessage` method to sign messages (as string):
68

79
<<< @./snippets/signing/sign-message.ts#signing-1{ts:line-numbers}
810

9-
The `wallet.signMessage` method internally hashes the message using the SHA-256 algorithm, then signs the hashed message, returning the signature as a hex string.
11+
The `signMessage` method internally:
12+
13+
- Hashes the message (via `hashMessage`)
14+
- Signs the hashed message using the wallet's private key
15+
- Returns the signature as a hex string
16+
17+
The `hashMessage` helper will:
18+
19+
- Performs a SHA-256 hash on the UTF-8 encoded message.
20+
21+
The `recoverAddress` method from the `Signer` class will take the hashed message and the signature to recover the signer's address. This confirms that the signature was created by the holder of the private key associated with that address, ensuring the authenticity and integrity of the signed message.
22+
23+
## Signing Personal Message
24+
25+
We can also sign arbitrary data, not just strings. This is possible by passing an object containing the `personalSign` property to the `hashMessage` and `signMessage` methods:
26+
27+
<<< @./snippets/signing/sign-personal-message.ts#signing-personal-message{ts:line-numbers}
28+
29+
The primary difference between this [personal message signing](#signing-personal-message) and [message signing](#signing-messages) is the underlying hashing format.
30+
31+
To format the message, we use a similar approach to a [EIP-191](https://eips.ethereum.org/EIPS/eip-191):
1032

11-
The `hashMessage` helper gives us the hash of the original message. This is crucial to ensure that the hash used during signing matches the one used during the address recovery process.
33+
```console
34+
\x19Fuel Signed Message:\n<message length><message>
35+
```
1236

13-
The `recoverAddress` method from the `Signer` class takes the hashed message and the signature to recover the signer's address. This confirms that the signature was created by the holder of the private key associated with that address, ensuring the authenticity and integrity of the signed message.
37+
> **Note**: We still hash using `SHA-256`, unlike Ethereum's [EIP-191](https://eips.ethereum.org/EIPS/eip-191) which uses `Keccak-256`.
1438
1539
## Signing Transactions
1640

apps/docs/src/guide/wallets/snippets/signing/sign-message.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
// #region signing-1
2-
import { hashMessage, Provider, Signer, WalletUnlocked } from 'fuels';
2+
import { hashMessage, Signer, WalletUnlocked } from 'fuels';
33

4-
import { LOCAL_NETWORK_URL } from '../../../../env';
4+
const wallet = WalletUnlocked.generate();
55

6-
const provider = new Provider(LOCAL_NETWORK_URL);
7-
8-
const wallet = WalletUnlocked.generate({ provider });
9-
10-
const message = 'my-message';
6+
const message: string = 'my-message';
117
const signedMessage = await wallet.signMessage(message);
128
// Example output: 0x277e1461cbb2e6a3250fa8c490221595efb3f4d66d43a4618d1013ca61ca56ba
139

@@ -24,3 +20,9 @@ console.log(
2420
'Recovered address should equal original wallet address',
2521
wallet.address.toB256() === recoveredAddress.toB256()
2622
);
23+
24+
console.log(
25+
'Hashed message should be consistent',
26+
hashedMessage ===
27+
'0x40436501b686546b7c660bb18791ac2ae35e77fbe2ac977fc061922b9ec83766'
28+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { hashMessage, Signer, WalletUnlocked } from 'fuels';
2+
3+
const wallet = WalletUnlocked.generate();
4+
5+
// #region signing-personal-message
6+
const message: string | Uint8Array = Uint8Array.from([0x01, 0x02, 0x03]);
7+
const signedMessage = await wallet.signMessage({ personalSign: message });
8+
// Example output: 0x0ca4ca2a01003d076b4044e38a7ca2443640d5fb493c37e28c582e4f2b47ada7
9+
10+
const hashedMessage = hashMessage({ personalSign: message });
11+
// Example output: 0x862e2d2c46b1b52fd65538c71f7ef209ee32f4647f939283b3dd2434cc5320c5
12+
// #endregion signing-personal-message
13+
14+
const recoveredAddress = Signer.recoverAddress(hashedMessage, signedMessage);
15+
16+
console.log(
17+
'Expect the recovered address to be the same as the original wallet address',
18+
wallet.address.toB256() === recoveredAddress.toB256()
19+
);
20+
21+
console.log(
22+
'Hashed message should be consistent',
23+
hashedMessage ===
24+
'0x862e2d2c46b1b52fd65538c71f7ef209ee32f4647f939283b3dd2434cc5320c5'
25+
);

packages/account/src/account.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AddressInput, WithAddress } from '@fuel-ts/address';
33
import { Address } from '@fuel-ts/address';
44
import { randomBytes } from '@fuel-ts/crypto';
55
import { ErrorCode, FuelError } from '@fuel-ts/errors';
6+
import type { HashableMessage } from '@fuel-ts/hasher';
67
import type { BigNumberish, BN } from '@fuel-ts/math';
78
import { bn } from '@fuel-ts/math';
89
import { InputType } from '@fuel-ts/transactions';
@@ -620,7 +621,7 @@ export class Account extends AbstractAccount implements WithAddress {
620621
*
621622
* @hidden
622623
*/
623-
async signMessage(message: string): Promise<string> {
624+
async signMessage(message: HashableMessage): Promise<string> {
624625
if (!this._connector) {
625626
throw new FuelError(ErrorCode.MISSING_CONNECTOR, 'A connector is required to sign messages.');
626627
}

packages/account/src/connectors/fuel-connector.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/require-await */
22
import { FuelError } from '@fuel-ts/errors';
3+
import type { HashableMessage } from '@fuel-ts/hasher';
34
import { EventEmitter } from 'events';
45

56
import type { Asset } from '../assets/types';
@@ -37,7 +38,7 @@ interface Connector {
3738
disconnect(): Promise<boolean>;
3839
// #endregion fuel-connector-method-disconnect
3940
// #region fuel-connector-method-signMessage
40-
signMessage(address: string, message: string): Promise<string>;
41+
signMessage(address: string, message: HashableMessage): Promise<string>;
4142
// #endregion fuel-connector-method-signMessage
4243
// #region fuel-connector-method-signTransaction
4344
signTransaction(address: string, transaction: TransactionRequestLike): Promise<string>;
@@ -178,7 +179,7 @@ export abstract class FuelConnector extends EventEmitter implements Connector {
178179
*
179180
* @returns Message signature
180181
*/
181-
async signMessage(_address: string, _message: string): Promise<string> {
182+
async signMessage(_address: string, _message: HashableMessage): Promise<string> {
182183
throw new FuelError(FuelError.CODES.NOT_IMPLEMENTED, 'Method not implemented.');
183184
}
184185

packages/account/src/signer/signer.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sha256 } from '@fuel-ts/hasher';
1+
import { hashMessage, sha256 } from '@fuel-ts/hasher';
22
import { arrayify } from '@fuel-ts/utils';
33

44
import { Signer } from './signer';
@@ -16,6 +16,8 @@ describe('Signer', () => {
1616
const expectedB256Address = '0xf1e92c42b90934aa6372e30bc568a326f6e66a1a0288595e6e3fbd392a4f3e6e';
1717
const expectedSignedMessage =
1818
'0x8eeb238db1adea4152644f1cd827b552dfa9ab3f4939718bb45ca476d167c6512a656f4d4c7356bfb9561b14448c230c6e7e4bd781df5ee9e5999faa6495163d';
19+
const expectedRawSignedMessage =
20+
'0x435f61b60f56a624b080e0b0066b8412094ca22b886f3e69ec4fe536bc18b576fc9732aa0b19c624b070b0eaeff45386aab8c5211618c9292e224e4cee0cadff';
1921

2022
it('Initialize publicKey and address for new signer instance', () => {
2123
const signer = new Signer(expectedPrivateKey);
@@ -35,13 +37,29 @@ describe('Signer', () => {
3537
expect(signer.address.toB256()).toEqual(expectedB256Address);
3638
});
3739

38-
it('Sign message', () => {
40+
it('Sign message [string]', () => {
3941
const signer = new Signer(expectedPrivateKey);
40-
const signedMessage = signer.sign(sha256(Buffer.from(expectedMessage)));
42+
const signedMessage = signer.sign(hashMessage(expectedMessage));
4143

4244
expect(signedMessage).toEqual(expectedSignedMessage);
4345
});
4446

47+
it('Sign raw message [{ personalSign: string }]', () => {
48+
const signer = new Signer(expectedPrivateKey);
49+
const message = new TextEncoder().encode(expectedMessage);
50+
const signedMessage = signer.sign(hashMessage({ personalSign: message }));
51+
52+
expect(signedMessage).toEqual(expectedRawSignedMessage);
53+
});
54+
55+
it('Sign raw message [{ personalSign: Uint8Array }]', () => {
56+
const signer = new Signer(expectedPrivateKey);
57+
const message = new TextEncoder().encode(expectedMessage);
58+
const signedMessage = signer.sign(hashMessage({ personalSign: message }));
59+
60+
expect(signedMessage).toEqual(expectedRawSignedMessage);
61+
});
62+
4563
it('Recover publicKey and address from signed message', () => {
4664
const signer = new Signer(expectedPrivateKey);
4765
const hashedMessage = sha256(Buffer.from(expectedMessage));

packages/account/src/wallet/base-wallet-unlocked.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { HashableMessage } from '@fuel-ts/hasher';
12
import { hashMessage } from '@fuel-ts/hasher';
23
import type { BytesLike } from '@fuel-ts/utils';
34
import { hexlify } from '@fuel-ts/utils';
@@ -67,7 +68,7 @@ export class BaseWalletUnlocked extends Account {
6768
* @param message - The message to sign.
6869
* @returns A promise that resolves to the signature as a ECDSA 64 bytes string.
6970
*/
70-
override async signMessage(message: string): Promise<string> {
71+
override async signMessage(message: HashableMessage): Promise<string> {
7172
const signedMessage = await this.signer().sign(hashMessage(message));
7273
return hexlify(signedMessage);
7374
}

packages/account/src/wallet/wallet-unlocked.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ describe('WalletUnlocked', () => {
2727
const expectedMessage = 'my message';
2828
const expectedSignedMessage =
2929
'0x8eeb238db1adea4152644f1cd827b552dfa9ab3f4939718bb45ca476d167c6512a656f4d4c7356bfb9561b14448c230c6e7e4bd781df5ee9e5999faa6495163d';
30+
const expectedRawSignedMessage =
31+
'0x435f61b60f56a624b080e0b0066b8412094ca22b886f3e69ec4fe536bc18b576fc9732aa0b19c624b070b0eaeff45386aab8c5211618c9292e224e4cee0cadff';
3032

3133
it('Instantiate a new wallet', async () => {
3234
using launched = await setupTestProviderAndWallets();
@@ -38,7 +40,7 @@ describe('WalletUnlocked', () => {
3840
expect(wallet.address.toAddress()).toEqual(expectedAddress);
3941
});
4042

41-
it('Sign a message using wallet instance', async () => {
43+
it('Sign a message using wallet instance [string]', async () => {
4244
using launched = await setupTestProviderAndWallets();
4345
const { provider } = launched;
4446

@@ -50,6 +52,38 @@ describe('WalletUnlocked', () => {
5052
expect(signedMessage).toEqual(expectedSignedMessage);
5153
});
5254

55+
it('Sign a raw message using wallet instance [{ personalSign: string }]', async () => {
56+
using launched = await setupTestProviderAndWallets();
57+
const { provider } = launched;
58+
59+
const wallet = new WalletUnlocked(expectedPrivateKey, provider);
60+
const message = expectedMessage;
61+
const signedMessage = await wallet.signMessage({ personalSign: message });
62+
const verifiedAddress = Signer.recoverAddress(
63+
hashMessage({ personalSign: message }),
64+
signedMessage
65+
);
66+
67+
expect(verifiedAddress).toEqual(wallet.address);
68+
expect(signedMessage).toEqual(expectedRawSignedMessage);
69+
});
70+
71+
it('Sign a raw message using wallet instance [{ personalSign: Uint8Array }]', async () => {
72+
using launched = await setupTestProviderAndWallets();
73+
const { provider } = launched;
74+
75+
const wallet = new WalletUnlocked(expectedPrivateKey, provider);
76+
const message = new TextEncoder().encode(expectedMessage);
77+
const signedMessage = await wallet.signMessage({ personalSign: message });
78+
const verifiedAddress = Signer.recoverAddress(
79+
hashMessage({ personalSign: message }),
80+
signedMessage
81+
);
82+
83+
expect(verifiedAddress).toEqual(wallet.address);
84+
expect(signedMessage).toEqual(expectedRawSignedMessage);
85+
});
86+
5387
it('Sign a transaction using wallet instance', async () => {
5488
using launched = await setupTestProviderAndWallets();
5589
const { provider } = launched;

0 commit comments

Comments
 (0)