Skip to content

Commit

Permalink
feat: add borsh schema to sign transaction (#3)
Browse files Browse the repository at this point in the history
This commit introduces borsh schema to define the serialization of a
transaction.

The borsh serialized transaction is the one to be signed, as it is the
same to be included on-chain.

The schema is always received as parameter so the snap remains stateless
& functional.
  • Loading branch information
vlopes11 authored Oct 16, 2023
1 parent 18732d5 commit 723b096
Show file tree
Hide file tree
Showing 10 changed files with 144 additions and 48 deletions.
1 change: 1 addition & 0 deletions packages/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@metamask/providers": "^9.0.0",
"borsh": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-is": "^18.2.0",
Expand Down
22 changes: 16 additions & 6 deletions packages/site/src/components/Sovereign.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import React, { useState } from 'react';
import { Schema } from 'borsh';
import { defaultSnapOrigin } from '../config';
import { ExecuteButton } from './Buttons';

type MethodSelectorState = `signMessage` | `getPublicKey`;
const callMessageSchema: Schema = {
struct: {
message: 'string',
},
};

type MethodSelectorState = `signTransaction` | `getPublicKey`;
type CurveSelectorState = `secp256k1` | `ed25519`;

type SovereignState = {
Expand All @@ -16,7 +23,7 @@ type SovereignState = {

export const Sovereign = () => {
const initialState: SovereignState = {
method: `signMessage`,
method: `signTransaction`,
curve: `secp256k1`,
keyId: 0,
message: 'Some signature message...',
Expand All @@ -36,7 +43,7 @@ export const Sovereign = () => {
})
}
>
<option value="signMessage">Sign Message</option>
<option value="signTransaction">Sign Transaction</option>
<option value="getPublicKey">Get Public Key</option>
</select>
</div>
Expand Down Expand Up @@ -77,7 +84,7 @@ export const Sovereign = () => {
<div>Signature message:</div>
<div>
<textarea
disabled={state.method !== `signMessage`}
disabled={state.method !== `signTransaction`}
value={state.message}
onChange={(ev) =>
setState({
Expand All @@ -99,11 +106,14 @@ export const Sovereign = () => {
path.push(keyId.toString());

let params;
if (method === `signMessage`) {
if (method === `signTransaction`) {
params = {
path,
curve,
message: message || '',
schema: callMessageSchema,
transaction: {
message: message || '',
},
};
} else {
params = {
Expand Down
3 changes: 2 additions & 1 deletion packages/site/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve"
"jsx": "preserve",
"experimentalDecorators": true
},
"include": ["src"]
}
49 changes: 43 additions & 6 deletions packages/snap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ if (
}
```

#### `signMessage`
#### `signTransaction`

Returns the signature of the message as hexadecimal string.

Expand All @@ -43,22 +43,59 @@ Will emit a confirmation dialog for the user.

- `path`: The BIP-32 derivation path of the wallet (`string['m', "44'", "1551'", ...]`).
- `curve`: The curve of the public key (`secp256k1` or `ed25519`).
- `message`: The message to sign (`bigint | number | string | Uint8Array`).
- `schema`: A [borsh](https://www.npmjs.com/package/borsh) schema for the transaction.
- `transaction`: A transaction to be serialized using the provided schema. The signature will be performed over the serialized transaction.

##### Example

```typescript
import { Schema } from 'borsh';

const callMessageSchema: Schema = {
enum: [
{
struct: {
Invoke: {
struct: {
method: 'string',
payload: { array: { type: 'u8' } },
},
},
},
},
{
struct: {
Transfer: {
struct: {
from: { array: { type: 'u8', len: 32 } },
to: { array: { type: 'u8', len: 32 } },
amount: 'u64',
},
},
},
},
],
};

const response = request({
method: 'signMessage',
method: 'signTransaction',
params: {
path: ['m', "44'", "1551'", "1'"],
path: ['m', "44'", "1551'"],
curve: 'ed25519',
message: 'some message',
schema: callMessageSchema,
transaction: {
Transfer: {
from: Array(32).fill(2),
to: Array(32).fill(3),
amount: 1582,
},
},
},
});

if (
!response ===
'0x10804459eef93e52f9f01f38775ce4a21eb818d70cb637c602267f48c4e129fb2f68bc24bf74c84a1950227ea76d7c1ce860e4867941ef793c83399621c69c0d'
'0xfd2e4b23a3e3f498664af355b341e833324276270a13f9647dd1f043248f92fccaa037d4cfc9d23f13a295f7d505ee13afb2b10cea548890678f9002947cbb0a'
) {
throw new Error('Invalid signature');
}
Expand Down
1 change: 1 addition & 0 deletions packages/snap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@metamask/utils": "^8.1.0",
"@noble/ed25519": "^1.6.0",
"@noble/secp256k1": "^1.7.1",
"borsh": "^1.0.0",
"buffer": "^6.0.3"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/Sovereign-Labs/sov-snap.git"
},
"source": {
"shasum": "ffF94Uc3L8/OZ4qDYWUAWbUDSs/a4v69Bg24WiT3Mvc=",
"shasum": "M4WoRFL+SjnLWRKANC/7e5s/+nwPabd2hvVY77TCck8=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
54 changes: 47 additions & 7 deletions packages/snap/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import { installSnap } from '@metamask/snaps-jest';
import { expect } from '@jest/globals';
import { assert } from '@metamask/utils';
import { Schema } from 'borsh';

const callMessageSchema: Schema = {
enum: [
{
struct: {
Invoke: {
struct: {
method: 'string',
payload: { array: { type: 'u8' } },
},
},
},
},
{
struct: {
Transfer: {
struct: {
from: { array: { type: 'u8', len: 32 } },
to: { array: { type: 'u8', len: 32 } },
amount: 'u64',
},
},
},
},
],
};

describe('onRpcRequest', () => {
describe('getPublicKey', () => {
Expand Down Expand Up @@ -41,16 +68,22 @@ describe('onRpcRequest', () => {
});
});

describe('signMessage', () => {
describe('signTransaction', () => {
it('returns a secp256k1 signature', async () => {
const { request, close } = await installSnap();

const response = request({
method: 'signMessage',
method: 'signTransaction',
params: {
path: ['m', "44'", "1551'"],
curve: 'secp256k1',
message: 'some message',
schema: callMessageSchema,
transaction: {
Invoke: {
method: 'someMethod',
payload: Array(176).fill(5),
},
},
},
});

Expand All @@ -59,7 +92,7 @@ describe('onRpcRequest', () => {
await ui.ok();

expect(await response).toRespondWith(
'0x3044022037e40728bd555a0b18a9a60e56eb1c3ad3f691c13df947a95c177491a23e8a2f02206eb555dd3061ae3fb13292dc90f742111c4329397e2323746bfa2296a478e4f5',
'0x3044022037ed1abe499f6699943dc26299e5c24111002ad115e481d126868e68e73eebd40220552312cc86a09ba8160ffc496b24eb04319c6c25ad7f965b59a6938858f00ca5',
);

await close();
Expand All @@ -69,11 +102,18 @@ describe('onRpcRequest', () => {
const { request, close } = await installSnap();

const response = request({
method: 'signMessage',
method: 'signTransaction',
params: {
path: ['m', "44'", "1551'"],
curve: 'ed25519',
message: 'some message',
schema: callMessageSchema,
transaction: {
Transfer: {
from: Array(32).fill(2),
to: Array(32).fill(3),
amount: 1582,
},
},
},
});

Expand All @@ -82,7 +122,7 @@ describe('onRpcRequest', () => {
await ui.ok();

expect(await response).toRespondWith(
'0x83207c0ef4117e2cb70fdf6bcce4ed0b54ec2047332205f81f480744375b14ba1239738d0883c21285f96d60259070988b1095e45d7cbb6782393eba2dfdd903',
'0xfd2e4b23a3e3f498664af355b341e833324276270a13f9647dd1f043248f92fccaa037d4cfc9d23f13a295f7d505ee13afb2b10cea548890678f9002947cbb0a',
);

await close();
Expand Down
36 changes: 14 additions & 22 deletions packages/snap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@ import { providerErrors, rpcErrors } from '@metamask/rpc-errors';
import { DialogType, OnRpcRequestHandler } from '@metamask/snaps-types';
import { copyable, heading, panel, text } from '@metamask/snaps-ui';
import { SLIP10Node } from '@metamask/key-tree';
import {
add0x,
assert,
bytesToHex,
remove0x,
valueToBytes,
} from '@metamask/utils';
import { add0x, assert, bytesToHex, remove0x } from '@metamask/utils';
import { sign as signEd25519 } from '@noble/ed25519';
import { sign as signSecp256k1 } from '@noble/secp256k1';
import { serialize } from 'borsh';

import type { GetBip32PublicKeyParams, SignMessageParams } from './types';
import type { GetBip32PublicKeyParams, SignTransactionParams } from './types';

/**
* Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`.
Expand All @@ -32,8 +27,12 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
params: request.params as unknown as GetBip32PublicKeyParams,
});

case 'signMessage': {
const { message, curve, ...params } = request.params as SignMessageParams;
case 'signTransaction': {
const { schema, transaction, curve, ...params } =
request.params as SignTransactionParams;

const serializedTransaction = serialize(schema, transaction);

const json = await snap.request({
method: 'snap_getBip32Entropy',
params: {
Expand All @@ -53,21 +52,15 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
assert(node.privateKey);
assert(curve === 'ed25519' || curve === 'secp256k1');

// assert the user's approval of the provided message. Note: this message will be a raw bytes
// representation. A human-friendly format is currently not supported by the API as the
// conversion is expected to be performed before the interaction with the Snap.
//
// For more information, refer to the tracking issue:
// https://github.com/Sovereign-Labs/sovereign-sdk/issues/982
const approved = await snap.request({
method: 'snap_dialog',
params: {
type: DialogType.Confirmation,
content: panel([
heading('Signature request'),
text(
`Do you want to ${curve} sign "${message}" with the following public key?`,
),
text(`Do you want to ${curve} sign`),
copyable(JSON.stringify(transaction)),
text(`with the following public key?`),
copyable(add0x(node.publicKey)),
]),
},
Expand All @@ -77,16 +70,15 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
throw providerErrors.userRejectedRequest();
}

const messageBytes = valueToBytes(message);
const privateKey = remove0x(node.privateKey);

let signed;
switch (curve) {
case 'ed25519':
signed = await signEd25519(messageBytes, privateKey);
signed = await signEd25519(serializedTransaction, privateKey);
break;
case 'secp256k1':
signed = await signSecp256k1(messageBytes, privateKey);
signed = await signSecp256k1(serializedTransaction, privateKey);
break;
default:
throw new Error(`Unsupported curve: ${String(curve)}.`);
Expand Down
15 changes: 10 additions & 5 deletions packages/snap/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Bytes } from '@metamask/utils';
import { Schema } from 'borsh';

/**
* The parameters for calling the `getPublicKey` JSON-RPC method.
Expand Down Expand Up @@ -29,16 +29,21 @@ export type GetBip32PublicKeyParams = {
};

/**
* The parameters for calling the `signMessage` JSON-RPC method.
* The parameters for calling the `signTransaction` JSON-RPC method.
*
* Note: For simplicity, these are not validated by the snap. In production, you
* should validate that the request object matches this type before using it.
*/
export type SignMessageParams = {
export type SignTransactionParams = {
/**
* The message to sign.
* The borsh schema of the transaction.
*/
message: Bytes;
schema: Schema;

/**
* The transaction to sign.
*/
transaction: any;

/**
* The BIP-32 path to the account.
Expand Down
Loading

0 comments on commit 723b096

Please sign in to comment.