Skip to content

Commit

Permalink
feat(aepp,wallet): 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 6e7b17d commit b7da81f
Show file tree
Hide file tree
Showing 10 changed files with 394 additions and 102 deletions.
10 changes: 9 additions & 1 deletion examples/browser/aepp/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
>
Pay for transaction
</a>
<a
href="#"
:class="{ active: view === 'TypedData' }"
@click="view = 'TypedData'"
>
Typed data
</a>
</div>

<Component
Expand All @@ -39,10 +46,11 @@ import Connect from './Connect.vue';
import Basic from './Basic.vue';
import Contracts from './Contracts.vue';
import PayForTx from './PayForTx.vue';
import TypedData from './TypedData.vue';
export default {
components: {
Connect, Basic, Contracts, PayForTx,
Connect, Basic, Contracts, PayForTx, TypedData,
},
data: () => ({ view: '' }),
computed: mapState(['aeSdk']),
Expand Down
170 changes: 170 additions & 0 deletions examples/browser/aepp/src/TypedData.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<template>
<h2>Domain</h2>
<div class="group">
<div>
<div>Name</div>
<div>
<input
:value="domain.name"
@input="domain.name = $event.target.value || null"
>
</div>
</div>
<div>
<div>Version</div>
<div>
<input
:value="domain.version"
@input="domain.version = $event.target.value || null"
>
</div>
</div>
<div>
<div>Network id</div>
<div>
<input
:value="domain.networkId"
@input="domain.networkId = $event.target.value || null"
>
</div>
</div>
<div>
<div>Contract address</div>
<div>
<input
:value="domain.contractAddress"
@input="domain.contractAddress = $event.target.value || null"
>
</div>
</div>
</div>

<h2>Data</h2>
<div class="group">
<div>
<div>Type</div>
<div>
<textarea
v-model="aci"
placeholder="Type as ACI JSON"
/>
</div>
</div>
<div>
<div>Data</div>
<div>
<textarea v-model="data" />
</div>
</div>
<div>
<div>Encoded data</div>
<Value :value="toPromise(() => dataEncoded)" />
</div>
<div>
<div>Hash</div>
<Value :value="toPromise(() => hash.toString('base64'))" />
</div>
</div>

<h2>Sign</h2>
<div class="group">
<button @click="signPromise = signTypedData()">
Sign
</button>
<div v-if="signPromise">
<div>Signature</div>
<Value :value="signPromise" />
</div>
</div>

<h2>Verify</h2>
<div class="group">
<div>
<div>Signature</div>
<div>
<input
v-model="verifySignature"
placeholder="sg-encoded"
>
</div>
</div>
<div>
<div>Signer address</div>
<div>
<input
v-model="verifyAddress"
placeholder="ak_..."
>
</div>
</div>
<button @click="verifyPromise = verifyTypedData()">
Verify
</button>
<div v-if="verifyPromise">
<div>Is signature correct</div>
<Value :value="verifyPromise" />
</div>
</div>
</template>

<script>
import { mapState } from 'vuex';
import {
hashTypedData, encodeFateValue, verify, decode,
} from '@aeternity/aepp-sdk';
import Value from './components/Value.vue';
export default {
components: {
Value,
},
data: () => ({
domain: {
name: 'Simple æpp',
version: 2,
networkId: 'ae_uat',
contractAddress: null,
},
aci: Value.methods.valueToString({
record: [
{ name: 'operation', type: 'string' },
{ name: 'parameter', type: 'int' },
],
}),
data: Value.methods.valueToString({
operation: 'test',
parameter: 42,
}),
signPromise: null,
verifySignature: null,
verifyAddress: null,
verifyPromise: null,
}),
computed: {
...mapState(['aeSdk']),
dataParsed() {
return JSON.parse(this.data);
},
aciParsed() {
return JSON.parse(this.aci);
},
dataEncoded() {
return encodeFateValue(this.dataParsed, this.aciParsed);
},
hash() {
return hashTypedData(this.dataEncoded, this.aciParsed, this.domain);
},
},
methods: {
async toPromise(getter) {
return getter();
},
signTypedData() {
return this.aeSdk.signTypedData(this.dataEncoded, this.aciParsed, this.domain);
},
async verifyTypedData() {
return verify(this.hash, decode(this.verifySignature), this.verifyAddress);
},
},
};
</script>
14 changes: 11 additions & 3 deletions examples/browser/wallet-iframe/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import {
MemoryAccount, generateKeyPair, AeSdkWallet, Node, CompilerHttp,
BrowserWindowMessageConnection, METHODS, WALLET_TYPE,
RpcConnectionDenyError, RpcRejectedByUserError,
RpcConnectionDenyError, RpcRejectedByUserError, unpackTx, decodeFateValue,
} from '@aeternity/aepp-sdk';
import Value from './Value.vue';
Expand Down Expand Up @@ -78,7 +78,7 @@ export default {
const genConfirmCallback = (actionName) => (aeppId, parameters, origin) => {
if (!confirm([
`Client ${aeppInfo[aeppId].name} with id ${aeppId} at ${origin} want to ${actionName}`,
JSON.stringify(parameters, null, 2),
Value.methods.valueToString(parameters),
].join('\n'))) {
throw new RpcRejectedByUserError();
}
Expand All @@ -87,7 +87,7 @@ export default {
class AccountMemoryProtected extends MemoryAccount {
async signTransaction(tx, { aeppRpcClientId: id, aeppOrigin, ...options } = {}) {
if (id != null) {
const opt = { ...options };
const opt = { ...options, unpackedTx: unpackTx(tx) };
if (opt.onCompiler) opt.onCompiler = '<Compiler>';
if (opt.onNode) opt.onNode = '<Node>';
genConfirmCallback(`sign transaction ${tx}`)(id, opt, aeppOrigin);
Expand All @@ -102,6 +102,14 @@ export default {
return super.signMessage(message, options);
}
async signTypedData(data, aci, { aeppRpcClientId: id, aeppOrigin, ...options }) {
if (id != null) {
const opt = { ...options, aci, decodedData: decodeFateValue(data, aci) };
genConfirmCallback(`sign typed data ${data}`)(id, opt, aeppOrigin);
}
return super.signTypedData(data, aci, options);
}
static generate() {
// TODO: can inherit parent method after implementing https://github.com/aeternity/aepp-sdk-js/issues/1672
return new AccountMemoryProtected(generateKeyPair().secretKey);
Expand Down
2 changes: 0 additions & 2 deletions examples/browser/wallet-web-extension/src/Popup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@

<script>
import browser from 'webextension-polyfill';
import { unpackTx } from '@aeternity/aepp-sdk';
export default {
data: () => ({
Expand All @@ -37,7 +36,6 @@ export default {
const data = new URL(location).searchParams.get('data');
if (data != null) {
const { aeppOrigin, action, popupId, ...params } = JSON.parse(data);
if (params.transaction) params.unpackedTx = unpackTx(params.transaction);
this.aeppOrigin = aeppOrigin;
this.action = action;
this.popupId = popupId;
Expand Down
26 changes: 22 additions & 4 deletions examples/browser/wallet-web-extension/src/background.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import browser from 'webextension-polyfill';
import {
AeSdkWallet, CompilerHttp, Node, MemoryAccount, generateKeyPair, BrowserRuntimeConnection,
WALLET_TYPE, RpcConnectionDenyError, RpcRejectedByUserError,
WALLET_TYPE, RpcConnectionDenyError, RpcRejectedByUserError, unpackTx, decodeFateValue,
} from '@aeternity/aepp-sdk';

function stringifyBigint(value) {
return JSON.stringify(
value,
(k, v) => (typeof v === 'bigint' ? `${v} (as BigInt)` : v),
2,
);
}

let popupCounter = 0;
async function confirmInPopup(parameters) {
const popupUrl = new URL(browser.runtime.getURL('./popup.html'));
const popupId = popupCounter;
popupCounter += 1;
popupUrl.searchParams.set('data', JSON.stringify({ ...parameters, popupId }));
popupUrl.searchParams.set('data', stringifyBigint({ ...parameters, popupId }));
await browser.windows.create({
url: popupUrl.toString(),
type: 'popup',
Expand Down Expand Up @@ -42,10 +50,10 @@ const genConfirmCallback = (action) => async (aeppId, parameters, aeppOrigin) =>
class AccountMemoryProtected extends MemoryAccount {
async signTransaction(transaction, { aeppRpcClientId: id, aeppOrigin, ...options } = {}) {
if (id != null) {
const opt = { ...options };
const opt = { ...options, transaction, unpackedTx: unpackTx(transaction) };
if (opt.onCompiler) opt.onCompiler = '<Compiler>';
if (opt.onNode) opt.onNode = '<Node>';
await genConfirmCallback('sign transaction')(id, { ...opt, transaction }, aeppOrigin);
await genConfirmCallback('sign transaction')(id, opt, aeppOrigin);
}
return super.signTransaction(transaction, options);
}
Expand All @@ -57,6 +65,16 @@ class AccountMemoryProtected extends MemoryAccount {
return super.signMessage(message, options);
}

async signTypedData(data, aci, { aeppRpcClientId: id, aeppOrigin, ...options }) {
if (id != null) {
const opt = {
...options, aci, data, decodedData: decodeFateValue(data, aci),
};
await genConfirmCallback('sign typed data')(id, opt, aeppOrigin);
}
return super.signTypedData(data, aci, options);
}

static generate() {
// TODO: can inherit parent method after implementing https://github.com/aeternity/aepp-sdk-js/issues/1672
return new AccountMemoryProtected(generateKeyPair().secretKey);
Expand Down
15 changes: 15 additions & 0 deletions src/AeSdkWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,21 @@ export default class AeSdkWallet extends AeSdk {
signature: Buffer.from(await this.signMessage(message, parameters)).toString('hex'),
};
},
[METHODS.signTypedData]: async ({
domain, aci, data, onAccount = this.address,
}, origin) => {
if (!this._isRpcClientConnected(id)) throw new RpcNotAuthorizeError();
if (!this.addresses().includes(onAccount)) {
throw new RpcPermissionDenyError(onAccount);
}

const parameters = {
...domain, onAccount, aeppOrigin: origin, aeppRpcClientId: id,
};
return {
signature: await this.signTypedData(data, aci, parameters),
};
},
},
),
};
Expand Down
19 changes: 16 additions & 3 deletions src/account/Rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,21 @@ export default class AccountRpc extends AccountBase {
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');
override async signTypedData(
data: Encoded.ContractBytearray,
aci: Parameters<AccountBase['signTypedData']>[1],
{
name, version, contractAddress, networkId,
}: Parameters<AccountBase['signTypedData']>[2] = {},
): Promise<Encoded.Signature> {
const { signature } = await this._rpcClient.request(METHODS.signTypedData, {
onAccount: this.address,
domain: {
name, version, networkId, contractAddress,
},
aci,
data,
});
return signature;
}
}
10 changes: 10 additions & 0 deletions src/aepp-wallet-communication/rpc/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Encoded } from '../../utils/encoder';
import { Domain, AciValue } from '../../utils/typed-data';
import { METHODS, SUBSCRIPTION_TYPES, WALLET_TYPE } from '../schema';
import { TransformNodeType } from '../../Node';
import { SignedTx } from '../../apis/node';
Expand Down Expand Up @@ -72,6 +73,15 @@ export interface WalletApi {
[METHODS.signMessage]: (
p: { message: string; onAccount: Encoded.AccountAddress }
) => Promise<{ signature: string }>;

[METHODS.signTypedData]: (
p: {
domain: Domain;
aci: AciValue;
data: Encoded.ContractBytearray;
onAccount: Encoded.AccountAddress;
},
) => Promise<{ signature: Encoded.Signature }>;
}

export interface AeppApi {
Expand Down
1 change: 1 addition & 0 deletions src/aepp-wallet-communication/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const enum METHODS {
connect = 'connection.open',
sign = 'transaction.sign',
signMessage = 'message.sign',
signTypedData = 'typedData.sign',
subscribeAddress = 'address.subscribe',
updateNetwork = 'networkId.update',
closeConnection = 'connection.close',
Expand Down
Loading

0 comments on commit b7da81f

Please sign in to comment.