Skip to content

Commit d597082

Browse files
committed
feat: support eip712 for starknet
implemented as described in argentlabs/argent-x#14
1 parent 3837e72 commit d597082

File tree

8 files changed

+339
-2
lines changed

8 files changed

+339
-2
lines changed

package-lock.json

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@types/jest": "^27.0.2",
4040
"@types/json-bigint": "^1.0.1",
4141
"@types/minimalistic-assert": "^1.0.1",
42+
"@types/object-hash": "^2.2.1",
4243
"@types/pako": "^1.0.2",
4344
"@types/url-join": "^4.0.1",
4445
"@typescript-eslint/eslint-plugin": "^5.0.0",
@@ -67,6 +68,7 @@
6768
"json-bigint": "^1.0.0",
6869
"minimalistic-assert": "^1.0.1",
6970
"pako": "^2.0.4",
71+
"superstruct": "^0.15.3",
7072
"url-join": "^4.0.1"
7173
},
7274
"lint-staged": {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export * as stark from './utils/stark';
1818
export * as ec from './utils/ellipticCurve';
1919
export * as uint256 from './utils/uint256';
2020
export * as shortString from './utils/shortString';
21+
export * as eip712 from './utils/eip712';

src/signer/default.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import assert from 'minimalistic-assert';
22

33
import { Provider } from '../provider';
4-
import { AddTransactionResponse, KeyPair, Transaction } from '../types';
4+
import { AddTransactionResponse, KeyPair, Signature, Transaction } from '../types';
5+
import { TypedData, getMessageHash } from '../utils/eip712';
56
import { sign } from '../utils/ellipticCurve';
67
import { addHexPrefix } from '../utils/encode';
78
import { hashMessage } from '../utils/hash';
@@ -69,4 +70,26 @@ export class Signer extends Provider implements SignerInterface {
6970
signature: [r, s],
7071
});
7172
}
73+
74+
/**
75+
* Sign an JSON object with the starknet private key and return the signature
76+
*
77+
* @param json - JSON object to be signed
78+
* @returns the signature of the JSON object
79+
* @throws {Error} if the JSON object is not a valid JSON
80+
*/
81+
public async sign(typedData: TypedData): Promise<Signature> {
82+
return sign(this.keyPair, await this.hash(typedData));
83+
}
84+
85+
/**
86+
* Hash a JSON object with pederson hash and return the hash
87+
*
88+
* @param json - JSON object to be hashed
89+
* @returns the hash of the JSON object
90+
* @throws {Error} if the JSON object is not a valid JSON
91+
*/
92+
public async hash(typedData: TypedData): Promise<string> {
93+
return getMessageHash(typedData, this.address);
94+
}
7295
}

src/signer/interface.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Provider } from '../provider';
2-
import { AddTransactionResponse, Transaction } from '../types';
2+
import { AddTransactionResponse, Signature, Transaction } from '../types';
3+
import { TypedData } from '../utils/eip712/types';
34

45
export abstract class SignerInterface extends Provider {
56
public abstract address: string;
@@ -14,4 +15,22 @@ export abstract class SignerInterface extends Provider {
1415
public abstract override addTransaction(
1516
transaction: Transaction
1617
): Promise<AddTransactionResponse>;
18+
19+
/**
20+
* Sign an JSON object with the starknet private key and return the signature
21+
*
22+
* @param json - JSON object to be signed
23+
* @returns the signature of the JSON object
24+
* @throws {Error} if the JSON object is not a valid JSON
25+
*/
26+
public abstract sign(typedData: TypedData): Promise<Signature>;
27+
28+
/**
29+
* Hash a JSON object with pederson hash and return the hash
30+
*
31+
* @param json - JSON object to be hashed
32+
* @returns the hash of the JSON object
33+
* @throws {Error} if the JSON object is not a valid JSON
34+
*/
35+
public abstract hash(typedData: TypedData): Promise<string>;
1736
}

src/utils/eip712/index.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { computeHashOnElements } from '../hash';
2+
import { BigNumberish } from '../number';
3+
import { encodeShortString } from '../shortString';
4+
import { getSelectorFromName } from '../stark';
5+
import { TypedData } from './types';
6+
import { validateTypedData } from './utils';
7+
8+
export * from './types';
9+
10+
/**
11+
* Get the dependencies of a struct type. If a struct has the same dependency multiple times, it's only included once
12+
* in the resulting array.
13+
*
14+
* @param {TypedData} typedData
15+
* @param {string} type
16+
* @param {string[]} [dependencies]
17+
* @return {string[]}
18+
*/
19+
export const getDependencies = (
20+
typedData: TypedData,
21+
type: string,
22+
dependencies: string[] = []
23+
): string[] => {
24+
// `getDependencies` is called by most other functions, so we validate the JSON schema here
25+
if (!validateTypedData(typedData)) {
26+
throw new Error('Typed data does not match JSON schema');
27+
}
28+
29+
if (dependencies.includes(type)) {
30+
return dependencies;
31+
}
32+
33+
if (!typedData.types[type]) {
34+
return dependencies;
35+
}
36+
37+
return [
38+
type,
39+
...typedData.types[type].reduce<string[]>(
40+
(previous, t) => [
41+
...previous,
42+
...getDependencies(typedData, t.type, previous).filter(
43+
(dependency) => !previous.includes(dependency)
44+
),
45+
],
46+
[]
47+
),
48+
];
49+
};
50+
51+
/**
52+
* Encode a type to a string. All dependant types are alphabetically sorted.
53+
*
54+
* @param {TypedData} typedData
55+
* @param {string} type
56+
* @return {string}
57+
*/
58+
export const encodeType = (typedData: TypedData, type: string): string => {
59+
const [primary, ...dependencies] = getDependencies(typedData, type);
60+
const types = [primary, ...dependencies.sort()];
61+
62+
return types
63+
.map((dependency) => {
64+
return `${dependency}(${typedData.types[dependency].map((t) => `${t.type} ${t.name}`)})`;
65+
})
66+
.join('');
67+
};
68+
69+
/**
70+
* Get a type string as hash.
71+
*
72+
* @param {TypedData} typedData
73+
* @param {string} type
74+
* @return {string}
75+
*/
76+
export const getTypeHash = (typedData: TypedData, type: string): string => {
77+
return getSelectorFromName(encodeType(typedData, type));
78+
};
79+
80+
/**
81+
* Encodes a single value to an ABI serialisable string, number or Buffer. Returns the data as tuple, which consists of
82+
* an array of ABI compatible types, and an array of corresponding values.
83+
*
84+
* @param {TypedData} typedData
85+
* @param {string} type
86+
* @param {any} data
87+
* @returns {[string, string]}
88+
*/
89+
const encodeValue = (typedData: TypedData, type: string, data: unknown): [string, string] => {
90+
if (typedData.types[type]) {
91+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
92+
return ['felt', getStructHash(typedData, type, data as Record<string, unknown>)];
93+
}
94+
95+
if (type === 'shortString') {
96+
return ['felt', encodeShortString(data as string)];
97+
}
98+
99+
if (type === 'felt*') {
100+
return ['felt', computeHashOnElements(data as string[])];
101+
}
102+
103+
return [type, data as string];
104+
};
105+
106+
/**
107+
* Encode the data to an ABI encoded Buffer. The data should be a key -> value object with all the required values. All
108+
* dependant types are automatically encoded.
109+
*
110+
* @param {TypedData} typedData
111+
* @param {string} type
112+
* @param {Record<string, any>} data
113+
*/
114+
export const encodeData = <T extends TypedData>(typedData: T, type: string, data: T['message']) => {
115+
const [types, values] = typedData.types[type].reduce<[string[], string[]]>(
116+
([ts, vs], field) => {
117+
if (data[field.name] === undefined || data[field.name] === null) {
118+
throw new Error(`Cannot encode data: missing data for '${field.name}'`);
119+
}
120+
121+
const value = data[field.name];
122+
const [t, encodedValue] = encodeValue(typedData, field.type, value);
123+
124+
return [
125+
[...ts, t],
126+
[...vs, encodedValue],
127+
];
128+
},
129+
[['felt'], [getTypeHash(typedData, type)]]
130+
);
131+
132+
return [types, values];
133+
};
134+
135+
/**
136+
* Get encoded data as a hash. The data should be a key -> value object with all the required values. All dependant
137+
* types are automatically encoded.
138+
*
139+
* @param {TypedData} typedData
140+
* @param {string} type
141+
* @param {Record<string, any>} data
142+
* @return {Buffer}
143+
*/
144+
export const getStructHash = <T extends TypedData>(
145+
typedData: T,
146+
type: string,
147+
data: T['message']
148+
) => {
149+
return computeHashOnElements(encodeData(typedData, type, data)[1]);
150+
};
151+
152+
/**
153+
* Get the EIP-191 encoded message to sign, from the typedData object. If `hash` is enabled, the message will be hashed
154+
* with Keccak256.
155+
*
156+
* @param {TypedData} typedData
157+
* @param {boolean} hash
158+
* @return {string}
159+
*/
160+
export const getMessageHash = (typedData: TypedData, account: BigNumberish): string => {
161+
const message = [
162+
encodeShortString('StarkNet Message'),
163+
getStructHash(typedData, 'EIP712Domain', typedData.domain),
164+
account,
165+
getStructHash(typedData, typedData.primaryType, typedData.message),
166+
];
167+
168+
return computeHashOnElements(message);
169+
};

0 commit comments

Comments
 (0)