Skip to content

Commit f30327a

Browse files
authored
Merge pull request #460 from Concordium/BRO-13-payload-decoding-in-the-browser-wallet-of-the-cis3-standard
[BRO-13] Payload decoding in the browser wallet of the CIS3 standard
2 parents b1adbad + 5abf799 commit f30327a

File tree

14 files changed

+322
-0
lines changed

14 files changed

+322
-0
lines changed

packages/browser-wallet-api-helpers/src/wallet-api-types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import type {
2222
DeployModulePayload,
2323
ConfigureBakerPayload,
2424
ConfigureDelegationPayload,
25+
ContractName,
26+
EntrypointName,
2527
} from '@concordium/web-sdk';
2628
import type { RpcTransport } from '@protobuf-ts/runtime-rpc';
2729
import { LaxNumberEnumValue, LaxStringEnumValue } from './util';
@@ -250,6 +252,29 @@ interface MainWalletApi {
250252
message: string | SignMessageObject
251253
): Promise<AccountTransactionSignature>;
252254

255+
/**
256+
* Sends a message of the CIS3 contract standard, to the Concordium Wallet and awaits the users action. If the user signs the message, this will resolve to the signature.
257+
*
258+
* @param contractAddress the {@link ContractAddress} of the contract
259+
* @param contractName the {@link ContractName} of the contract
260+
* @param entrypointName the {@link EntrypointName} of the contract
261+
* @param nonce the nonce (CIS3 standard) that was part of the message that was signed
262+
* @param expiryTimeSignature RFC 3339 format (e.g. 2030-08-08T05:15:00Z)
263+
* @param accountAddress the address of the account that should sign the message
264+
* @param payloadMessage payload message to be signed, complete CIS3 message will be created from provided parameters. Note that the wallet will prepend some bytes to ensure the message cannot be a transaction. The message should be { @link SignMessageObject }.
265+
*
266+
* @throws if the user rejects signing the message.
267+
*/
268+
signCIS3Message(
269+
contractAddress: ContractAddress.Type,
270+
contractName: ContractName.Type,
271+
entrypointName: EntrypointName.Type,
272+
nonce: bigint | number,
273+
expiryTimeSignature: string,
274+
accountAddress: AccountAddressSource,
275+
payloadMessage: SignMessageObject
276+
): Promise<AccountTransactionSignature>;
277+
253278
/**
254279
* Requests a connection to the Concordium wallet, prompting the user to either accept or reject the request.
255280
* If a connection has already been accepted for the url once the returned promise will resolve without prompting the user.

packages/browser-wallet-api/src/wallet-api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
SchemaVersion,
1313
ContractAddress,
1414
VerifiablePresentation,
15+
ContractName,
16+
EntrypointName,
1517
} from '@concordium/web-sdk/types';
1618
import { CredentialStatements } from '@concordium/web-sdk/web3-id';
1719
import {
@@ -79,6 +81,38 @@ class WalletApi extends EventEmitter implements IWalletApi {
7981
return response.result;
8082
}
8183

84+
public async signCIS3Message(
85+
contractAddress: ContractAddress.Type,
86+
contractName: ContractName.Type,
87+
entrypointName: EntrypointName.Type,
88+
nonce: bigint | number,
89+
expiryTimeSignature: string,
90+
accountAddress: AccountAddressSource,
91+
payloadMessage: SignMessageObject
92+
): Promise<AccountTransactionSignature> {
93+
const input = sanitizeSignMessageInput(accountAddress, payloadMessage);
94+
const response = await this.messageHandler.sendMessage<MessageStatusWrapper<AccountTransactionSignature>>(
95+
MessageType.SignCIS3Message,
96+
{
97+
message: input.message,
98+
accountAddress: AccountAddress.toBase58(input.accountAddress),
99+
cis3ContractDetails: {
100+
contractAddress,
101+
contractName,
102+
entrypointName,
103+
nonce,
104+
expiryTimeSignature,
105+
},
106+
}
107+
);
108+
109+
if (!response.success) {
110+
throw new Error(response.message);
111+
}
112+
113+
return response.result;
114+
}
115+
82116
/**
83117
* Requests connection to wallet. Resolves with account address or rejects if rejected in wallet.
84118
*/

packages/browser-wallet-message-hub/src/message.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export enum MessageType {
1919
ConnectAccounts = 'M_ConnectAccounts',
2020
AddWeb3IdCredential = 'M_AddWeb3IdCredential',
2121
AddWeb3IdCredentialFinish = 'M_AddWeb3IdCredentialFinish',
22+
SignCIS3Message = 'M_SignCIS3Message',
2223
}
2324

2425
/**
@@ -49,6 +50,7 @@ export enum InternalMessageType {
4950
ImportWeb3IdBackup = 'I_ImportWeb3IdBackup',
5051
AbortRecovery = 'I_AbortRecovery',
5152
OpenFullscreen = 'I_OpenFullscreen',
53+
SignCIS3Message = 'I_SignCIS3Message',
5254
}
5355

5456
// eslint-disable-next-line @typescript-eslint/no-explicit-any

packages/browser-wallet/src/background/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,14 @@ forwardToPopup(
587587
undefined,
588588
withPromptEnd
589589
);
590+
forwardToPopup(
591+
MessageType.SignCIS3Message,
592+
InternalMessageType.SignCIS3Message,
593+
runConditionComposer(runIfAccountIsAllowlisted, ensureMessageWithSchemaParse, withPromptStart()),
594+
appendUrlToPayload,
595+
undefined,
596+
withPromptEnd
597+
);
590598
forwardToPopup(
591599
MessageType.AddTokens,
592600
InternalMessageType.AddTokens,

packages/browser-wallet/src/popup/constants/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export const relativeRoutes = {
5757
signMessage: {
5858
path: 'sign-message',
5959
},
60+
signCIS3Message: {
61+
path: 'sign-cis3-message',
62+
},
6063
sendTransaction: {
6164
path: 'send-transaction',
6265
},
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
$message-details-horizontal-padding: rem(20px);
2+
3+
.sign-cis3-message {
4+
&__details {
5+
display: flex;
6+
flex-direction: column;
7+
align-items: center;
8+
background-color: $color-bg;
9+
height: 100%;
10+
border: rem(1px) solid $color-grey;
11+
border-radius: rem(10px);
12+
padding: rem(10px) $message-details-horizontal-padding;
13+
14+
:where(&) h5 {
15+
margin-top: rem(10px);
16+
margin-bottom: 0;
17+
}
18+
}
19+
20+
&__details-text-area textarea {
21+
min-height: 10rem;
22+
border-top-left-radius: 0;
23+
border-top-right-radius: 0;
24+
font-family: $font-family-mono;
25+
padding: rem(10px);
26+
}
27+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import React, { useCallback, useContext, useEffect, useState } from 'react';
2+
import { useLocation } from 'react-router-dom';
3+
import clsx from 'clsx';
4+
import { stringify } from 'json-bigint';
5+
import { useTranslation } from 'react-i18next';
6+
import { useSetAtom } from 'jotai';
7+
import { Buffer } from 'buffer/';
8+
import {
9+
AccountAddress,
10+
AccountTransactionSignature,
11+
buildBasicAccountSigner,
12+
ContractAddress,
13+
ContractName,
14+
deserializeTypeValue,
15+
EntrypointName,
16+
serializeTypeValue,
17+
signMessage,
18+
} from '@concordium/web-sdk';
19+
import { fullscreenPromptContext } from '@popup/page-layouts/FullscreenPromptLayout';
20+
import { usePrivateKey } from '@popup/shared/utils/account-helpers';
21+
import { displayUrl } from '@popup/shared/utils/string-helpers';
22+
import { TextArea } from '@popup/shared/Form/TextArea';
23+
import ConnectedBox from '@popup/pages/Account/ConnectedBox';
24+
import Button from '@popup/shared/Button';
25+
import { addToastAtom } from '@popup/state';
26+
import ExternalRequestLayout from '@popup/page-layouts/ExternalRequestLayout';
27+
import { SignMessageObject } from '@concordium/browser-wallet-api-helpers';
28+
29+
const SERIALIZATION_HELPER_SCHEMA =
30+
'FAAFAAAAEAAAAGNvbnRyYWN0X2FkZHJlc3MMBQAAAG5vbmNlBQkAAAB0aW1lc3RhbXANCwAAAGVudHJ5X3BvaW50FgEHAAAAcGF5bG9hZBABAg==';
31+
32+
type Props = {
33+
onSubmit(signature: AccountTransactionSignature): void;
34+
onReject(): void;
35+
};
36+
37+
interface Location {
38+
state: {
39+
payload: {
40+
accountAddress: string;
41+
message: SignMessageObject;
42+
url: string;
43+
cis3ContractDetails: Cis3ContractDetailsObject;
44+
};
45+
};
46+
}
47+
48+
type Cis3ContractDetailsObject = {
49+
contractAddress: ContractAddress.Type;
50+
contractName: ContractName.Type;
51+
entrypointName: EntrypointName.Type;
52+
nonce: bigint | number;
53+
expiryTimeSignature: string;
54+
};
55+
56+
async function parseMessage(message: SignMessageObject) {
57+
return stringify(
58+
deserializeTypeValue(Buffer.from(message.data, 'hex'), Buffer.from(message.schema, 'base64')),
59+
undefined,
60+
2
61+
);
62+
}
63+
64+
function serializeMessage(payloadMessage: SignMessageObject, cis3ContractDetails: Cis3ContractDetailsObject) {
65+
const { contractAddress, entrypointName, nonce, expiryTimeSignature } = cis3ContractDetails;
66+
const message = {
67+
contract_address: {
68+
index: Number(contractAddress.index),
69+
subindex: Number(contractAddress.subindex),
70+
},
71+
nonce: Number(nonce),
72+
timestamp: expiryTimeSignature,
73+
entry_point: EntrypointName.toString(entrypointName),
74+
payload: Array.from(Buffer.from(payloadMessage.data, 'hex')),
75+
};
76+
77+
return serializeTypeValue(message, Buffer.from(SERIALIZATION_HELPER_SCHEMA, 'base64'));
78+
}
79+
80+
function MessageDetailsDisplay({
81+
payloadMessage,
82+
cis3ContractDetails,
83+
}: {
84+
payloadMessage: SignMessageObject;
85+
cis3ContractDetails: Cis3ContractDetailsObject;
86+
}) {
87+
const { t } = useTranslation('signCIS3Message');
88+
const { contractAddress, contractName, entrypointName, nonce, expiryTimeSignature } = cis3ContractDetails;
89+
const [parsedMessage, setParsedMessage] = useState<string>('');
90+
const expiry = new Date(expiryTimeSignature).toString();
91+
92+
useEffect(() => {
93+
parseMessage(payloadMessage)
94+
.then((m) => setParsedMessage(m))
95+
.catch(() => setParsedMessage(t('unableToDeserialize')));
96+
}, []);
97+
98+
return (
99+
<div className="m-10 sign-cis3-message__details">
100+
<h5>{t('contractIndex')}:</h5>
101+
<div>
102+
{contractAddress.index.toString()} ({contractAddress.subindex.toString()})
103+
</div>
104+
<h5>{t('receiveName')}:</h5>
105+
<div>
106+
{contractName.value.toString()}.{entrypointName.value.toString()}
107+
</div>
108+
<h5>{t('nonce')}:</h5>
109+
<div>{nonce.toString()}</div>
110+
<h5>{t('expiry')}:</h5>
111+
<div>{expiry}</div>
112+
<h5>{t('parameter')}:</h5>
113+
<TextArea
114+
readOnly
115+
className={clsx('m-b-10 w-full flex-child-fill sign-cis3-message__details-text-area')}
116+
value={parsedMessage}
117+
/>
118+
</div>
119+
);
120+
}
121+
122+
export default function SignCIS3Message({ onSubmit, onReject }: Props) {
123+
const { state } = useLocation() as Location;
124+
const { t } = useTranslation('signCIS3Message');
125+
const { withClose } = useContext(fullscreenPromptContext);
126+
const { accountAddress, url, message, cis3ContractDetails } = state.payload;
127+
const key = usePrivateKey(accountAddress);
128+
const addToast = useSetAtom(addToastAtom);
129+
const onClick = useCallback(async () => {
130+
if (!key) {
131+
throw new Error('Missing key for the chosen address');
132+
}
133+
134+
return signMessage(
135+
AccountAddress.fromBase58(accountAddress),
136+
serializeMessage(message, cis3ContractDetails).buffer,
137+
buildBasicAccountSigner(key)
138+
);
139+
}, [state.payload.message, state.payload.accountAddress, key]);
140+
141+
return (
142+
<ExternalRequestLayout className="p-10">
143+
<ConnectedBox accountAddress={accountAddress} url={new URL(url).origin} />
144+
<div className="h-full flex-column align-center">
145+
<h3 className="m-t-0 text-center">{t('description', { dApp: displayUrl(url) })}</h3>
146+
<p className="m-t-0 text-center">{t('descriptionWithSchema', { dApp: displayUrl(url) })}</p>
147+
<MessageDetailsDisplay payloadMessage={message} cis3ContractDetails={cis3ContractDetails} />
148+
<br />
149+
<div className="flex p-b-10 p-t-10 m-t-auto">
150+
<Button width="narrow" className="m-r-10" onClick={withClose(onReject)}>
151+
{t('reject')}
152+
</Button>
153+
<Button
154+
width="narrow"
155+
onClick={() =>
156+
onClick()
157+
.then(withClose(onSubmit))
158+
.catch((e) => addToast(e.message))
159+
}
160+
>
161+
{t('sign')}
162+
</Button>
163+
</div>
164+
</div>
165+
</ExternalRequestLayout>
166+
);
167+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type en from './en';
2+
3+
const t: typeof en = {
4+
description: '{{ dApp }} anmoder om en signatur på følgende besked',
5+
descriptionWithSchema:
6+
'{{ dApp }} har sendt en rå besked og et schema til at oversætte den. Vi har oversat beskeden, men du burde kun underskrive hvis du stoler på {{ dApp }}',
7+
unableToDeserialize: 'Det var ikke muligt at oversætte beskeden',
8+
contractIndex: 'Kontrakt indeks (under indeks)',
9+
receiveName: 'Kontrakt og funktions navn',
10+
parameter: 'Parameter',
11+
nonce: 'Nonce',
12+
expiry: 'Udløber',
13+
sign: 'Signér',
14+
reject: 'Afvis',
15+
};
16+
17+
export default t;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const t = {
2+
description: '{{ dApp }} requests a signature on a message',
3+
descriptionWithSchema:
4+
"{{ dApp }} has provided the raw message and a schema to render it. We've rendered the message but you should only sign it if you trust {{ dApp }}.",
5+
unableToDeserialize: 'Unable to render message',
6+
contractIndex: 'Contract index (subindex)',
7+
receiveName: 'Contract and function name',
8+
parameter: 'Parameter',
9+
nonce: 'Nonce',
10+
expiry: 'Expiry time',
11+
sign: 'Sign',
12+
reject: 'Reject',
13+
};
14+
15+
export default t;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './SignCIS3Message';

packages/browser-wallet/src/popup/shell/Routes.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import FullscreenPromptLayout from '@popup/page-layouts/FullscreenPromptLayout';
1515
import Account from '@popup/pages/Account';
1616
import Identity from '@popup/pages/Identity';
1717
import SignMessage from '@popup/pages/SignMessage';
18+
import SignCIS3Message from '@popup/pages/SignCIS3Message';
1819
import SendTransaction from '@popup/pages/SendTransaction';
1920
import Setup from '@popup/pages/Setup';
2021
import ConnectionRequest from '@popup/pages/ConnectionRequest';
@@ -106,6 +107,10 @@ export default function Routes() {
106107
InternalMessageType.SignMessage,
107108
'signMessage'
108109
);
110+
const handleSignCIS3MessageResponse = useMessagePrompt<MessageStatusWrapper<AccountTransactionSignature>>(
111+
InternalMessageType.SignCIS3Message,
112+
'signCIS3Message'
113+
);
109114
const handleAddTokensResponse = useMessagePrompt<MessageStatusWrapper<string[]>>(
110115
InternalMessageType.AddTokens,
111116
'addTokens'
@@ -155,6 +160,19 @@ export default function Routes() {
155160
/>
156161
}
157162
/>
163+
<Route
164+
path={relativeRoutes.prompt.signCIS3Message.path}
165+
element={
166+
<SignCIS3Message
167+
onSubmit={(signature) =>
168+
handleSignCIS3MessageResponse({ success: true, result: signature })
169+
}
170+
onReject={() =>
171+
handleSignCIS3MessageResponse({ success: false, message: 'Signing was rejected' })
172+
}
173+
/>
174+
}
175+
/>
158176
<Route
159177
path={relativeRoutes.prompt.sendTransaction.path}
160178
element={

0 commit comments

Comments
 (0)