Skip to content

Commit

Permalink
feat(account): support signing typed data
Browse files Browse the repository at this point in the history
  • Loading branch information
davidyuk committed Jun 18, 2023
1 parent 7509795 commit c3355fe
Show file tree
Hide file tree
Showing 14 changed files with 323 additions and 12 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"blakejs": "^1.2.1",
"bs58": "^5.0.0",
"buffer": "^6.0.3",
"canonicalize": "^2.0.0",
"events": "^3.3.0",
"isomorphic-ws": "^5.0.0",
"json-bigint": "^1.0.0",
Expand Down
8 changes: 8 additions & 0 deletions src/AeSdkBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ export default class AeSdkBase extends AeSdkMethods {
return this._resolveAccount(onAccount).signMessage(message, options);
}

async signTypedData(
data: Encoded.ContractBytearray,
aci: Parameters<AccountBase['signTypedData']>[1],
{ onAccount, ...options }: { onAccount?: OnAccount } & Parameters<AccountBase['signTypedData']>[2] = {},
): Promise<Encoded.Signature> {
return this._resolveAccount(onAccount).signTypedData(data, aci, options);
}

override _getOptions(callOptions: AeSdkMethodsOptions = {}): {
onNode: Node;
onAccount: AccountBase;
Expand Down
21 changes: 19 additions & 2 deletions src/account/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Encoded } from '../utils/encoder';
import Node from '../Node';
import CompilerBase from '../contract/compiler/Base';
import { Int } from '../tx/builder/constants';
import { AciValue, Domain } from '../utils/typed-data';

interface AuthData {
fee?: Int;
Expand Down Expand Up @@ -42,7 +43,7 @@ export default abstract class AccountBase {
* Sign message
* @param message - Message to sign
* @param options - Options
* @returns Signature as hex string of Uint8Array
* @returns Signature
*/
abstract signMessage(
message: string,
Expand All @@ -52,11 +53,27 @@ export default abstract class AccountBase {
},
): Promise<Uint8Array>;

/**
* Sign typed data
* @param type - Type of data to sign
* @param data - Encoded data to sign
* @param options - Options
* @returns Signature
*/
abstract signTypedData(
data: Encoded.ContractBytearray,
aci: AciValue,
options?: Domain & {
aeppOrigin?: string;
aeppRpcClientId?: string;
},
): Promise<Encoded.Signature>;

/**
* Sign data blob
* @param data - Data blob to sign
* @param options - Options
* @returns Signed data blob
* @returns Signature
*/
abstract sign(data: string | Uint8Array, options?: any): Promise<Uint8Array>;

Expand Down
5 changes: 5 additions & 0 deletions src/account/Generalized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export default class AccountGeneralized extends AccountBase {
throw new NotImplementedError('Can\'t sign using generalized account');
}

// eslint-disable-next-line class-methods-use-this
override async signTypedData(): Promise<Encoded.Signature> {
throw new NotImplementedError('Can\'t sign using generalized account');
}

override async signTransaction(
tx: Encoded.Transaction,
{ authData, onCompiler, onNode }: Parameters<AccountBase['signTransaction']>[1],
Expand Down
5 changes: 5 additions & 0 deletions src/account/Ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export default class AccountLedger extends AccountBase {
throw new NotImplementedError('RAW signing using Ledger HW');
}

// eslint-disable-next-line class-methods-use-this
override async signTypedData(): Promise<Encoded.Signature> {
throw new NotImplementedError('Typed data signing using Ledger HW');
}

override async signTransaction(
tx: Encoded.Transaction,
{ innerTx, networkId }: { innerTx?: boolean; networkId?: string } = {},
Expand Down
15 changes: 15 additions & 0 deletions src/account/Memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
decode, encode, Encoded, Encoding,
} from '../utils/encoder';
import { concatBuffers } from '../utils/other';
import { hashTypedData, AciValue } from '../utils/typed-data';
import { buildTx } from '../tx/builder';
import { Tag } from '../tx/builder/constants';

Expand Down Expand Up @@ -74,4 +75,18 @@ export default class AccountMemory extends AccountBase {
override async signMessage(message: string, options?: any): Promise<Uint8Array> {
return this.sign(messageToHash(message), options);
}

override async signTypedData(
data: Encoded.ContractBytearray,
aci: AciValue,
{
name, version, networkId, contractAddress, ...options
}: Parameters<AccountBase['signTypedData']>[2] = {},
): Promise<Encoded.Signature> {
const dHash = hashTypedData(data, aci, {
name, version, networkId, contractAddress,
});
const signature = await this.sign(dHash, options);
return encode(signature, Encoding.Signature);
}
}
11 changes: 5 additions & 6 deletions src/account/Rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ export default class AccountRpc extends AccountBase {
throw new NotImplementedError('RAW signing using wallet');
}

/**
* @returns Signed transaction
*/
override async signTransaction(
tx: Encoded.Transaction,
{ innerTx, networkId }: Parameters<AccountBase['signTransaction']>[1] = {},
Expand All @@ -49,12 +46,14 @@ export default class AccountRpc extends AccountBase {
return res.signedTransaction;
}

/**
* @returns Signed message
*/
override async signMessage(message: string): Promise<Uint8Array> {
const { signature } = await this._rpcClient
.request(METHODS.signMessage, { onAccount: this.address, message });
return Buffer.from(signature, 'hex');
}

// eslint-disable-next-line class-methods-use-this
override async signTypedData(): Promise<Encoded.Signature> {
throw new NotImplementedError('Typed data signing using wallet');
}
}
3 changes: 3 additions & 0 deletions src/index-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export {
export {
encode, decode, Encoding, Encoded,
} from './utils/encoder';
export {
encodeFateValue, decodeFateValue, hashTypedData, hashDomain, hashJson,
} from './utils/typed-data';
export {
aensRevoke, aensUpdate, aensTransfer, aensQuery, aensClaim, aensPreclaim, aensBid,
} from './aens';
Expand Down
3 changes: 3 additions & 0 deletions src/utils/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export function verify(
return nacl.sign.detached.verify(data, signature, decode(address));
}

// TODO: consider rename to hashMessage
export function messageToHash(message: string): Buffer {
const p = Buffer.from('aeternity Signed Message:\n', 'utf8');
const msg = Buffer.from(message, 'utf8');
Expand All @@ -174,6 +175,8 @@ export function signMessage(message: string, privateKey: string | Buffer): Uint8
* @param address - Address to verify against
* @returns is data was signed by address
*/
// TODO: deprecate in favour of `verify(messageToHash(message), ...`, also the name is confusing
// it should contain "signature"
export function verifyMessage(
message: string,
signature: Uint8Array,
Expand Down
90 changes: 90 additions & 0 deletions src/utils/typed-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @ts-expect-error see https://github.com/aeternity/aepp-calldata-js/issues/216
import ContractByteArrayEncoder from '@aeternity/aepp-calldata/src/ContractByteArrayEncoder';
// @ts-expect-error see https://github.com/aeternity/aepp-calldata-js/issues/216
import AciTypeResolver from '@aeternity/aepp-calldata/src/AciTypeResolver';
import canonicalize from 'canonicalize';
import { Encoded, decode } from './encoder';
import { hash } from './crypto';
import { concatBuffers } from './other';

/**
* Hashes arbitrary object, can be used to inline the aci hash to contract source code
*/
export function hashJson(data: unknown): Buffer {
return hash(canonicalize(data) ?? '');
}

// TODO: move this type to calldata library https://github.com/aeternity/aepp-calldata-js/issues/215
// based on https://github.com/aeternity/aepp-calldata-js/blob/82b5a98f9b308482627da8d7484d213e9cf87151/src/AciTypeResolver.js#L129
export type AciValue = 'void' | 'unit' | 'int' | 'bool' | 'string' | 'bits' | 'hash' | 'signature'
| 'address' | 'contract_pubkey' | 'Chain.ttl' | 'Chain.ga_meta_tx' | 'Chain.paying_for_tx'
| 'Chain.base_tx' | 'AENS.pointee' | 'AENS.name' | 'MCL_BLS12_381.fr' | 'MCL_BLS12_381.fp'
| { 'Set.set': readonly [AciValue] }
| { bytes: number }
| { list: readonly [AciValue] }
| { map: readonly [AciValue, AciValue] }
| { tuple: readonly AciValue[] }
| { record: ReadonlyArray<{ name: string; type: AciValue }> }
| { variant: ReadonlyArray<{ [key: string]: readonly AciValue[] }> }
| { option: readonly [AciValue] }
| { oracle: readonly [AciValue, AciValue] }
| { oracle_query: readonly [AciValue, AciValue] };

export interface Domain {
name?: string;
version?: number;
networkId?: string;
contractAddress?: Encoded.ContractAddress;
}

// TODO: replace with api on calldata side https://github.com/aeternity/aepp-calldata-js/issues/216
export function encodeFateValue(
value: unknown,
aci: AciValue,
): Encoded.ContractBytearray {
const contractByteArrayEncoder = new ContractByteArrayEncoder();
const aciTypeResolver = new AciTypeResolver([]);
aciTypeResolver.isCustomType = () => false;
return contractByteArrayEncoder.encode(aciTypeResolver.resolveType(aci), value);
}

// TODO: replace with api on calldata side https://github.com/aeternity/aepp-calldata-js/issues/216
export function decodeFateValue(
value: Encoded.ContractBytearray,
aci: AciValue,
): Encoded.ContractBytearray {
const contractByteArrayEncoder = new ContractByteArrayEncoder();
const aciTypeResolver = new AciTypeResolver([]);
aciTypeResolver.isCustomType = () => false;
return contractByteArrayEncoder.decodeWithType(value, aciTypeResolver.resolveType(aci));
}

/**
* Hashes domain object, can be used to inline domain hash to contract source code
*/
export function hashDomain(domain: Domain): Buffer {
const domainAci = {
record: [{
name: 'name',
type: { option: ['string'] },
}, {
name: 'version',
type: { option: ['int'] },
}, {
name: 'networkId',
type: { option: ['string'] },
}, {
name: 'contractAddress',
type: { option: ['contract_pubkey'] },
}],
} as const;
return hash(decode(encodeFateValue(domain, domainAci)));
}

export function hashTypedData(
data: Encoded.ContractBytearray,
aci: AciValue,
domain: Domain,
): Buffer {
return hash(concatBuffers([hashDomain(domain), hashJson(aci), hash(decode(data))]));
}
2 changes: 1 addition & 1 deletion test/integration/Middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('MiddlewareSubscriber', () => {
},
mdwGensPerMinute: res.mdwGensPerMinute,
mdwHeight: res.mdwHeight,
mdwLastMigration: 20230519120000,
mdwLastMigration: res.mdwLastMigration,
mdwRevision: res.mdwRevision,
mdwSynced: true,
mdwSyncing: true,
Expand Down
Loading

0 comments on commit c3355fe

Please sign in to comment.