Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ECDSA signature support #147

Merged
merged 3 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": false
}
1 change: 1 addition & 0 deletions packages/snap/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
build/
dist/
coverage/
yarn-error.log
27 changes: 27 additions & 0 deletions packages/snap/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ <h1>Hello, BTC Snaps!</h1>
<button class="saveData">Save data to Snap</button>
<button class="getData">Get data from Snap</button>
<button class="signLNInvoice">Sign LN Invoice</button>
<button class="signMessage">Sign Message</button>

<div id="extendedPubKey" style="margin-top:5px"></div>
<div id="mfp" style="margin-top:5px"></div>
Expand All @@ -51,6 +52,7 @@ <h1>Hello, BTC Snaps!</h1>
const saveDataButton = document.querySelector('button.saveData')
const getDataButton = document.querySelector('button.getData')
const signLNInvoiceButton = document.querySelector('button.signLNInvoice')
const signMessageButton = document.querySelector('button.signMessage')

connectButton.addEventListener('click', connect)
sendButton.addEventListener('click', getPubKey)
Expand All @@ -60,6 +62,8 @@ <h1>Hello, BTC Snaps!</h1>
saveDataButton.addEventListener('click', saveData)
getDataButton.addEventListener('click', getData)
signLNInvoiceButton.addEventListener('click', signLNInvoice)
signMessageButton.addEventListener('click', signMessage);


// here we get permissions to interact with and install the snap
async function connect () {
Expand Down Expand Up @@ -232,5 +236,28 @@ <h1>Hello, BTC Snaps!</h1>
alert('Problem happened: ' + err.message || err)
}
}

async function signMessage () {
try {
const response = await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId,
request: {
method: 'btc_signMessage',
params: {
message: "Hello, world!",
derivationPath: "m/49'/0'/0'/0/0",
}
}
}
})

console.log(response);
} catch(err) {
console.error(err)
alert('Problem happened: ' + err.message || err)
}
}
</script>
</html>
3 changes: 2 additions & 1 deletion packages/snap/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "btcsnap",
"version": "2.0.3",
"version": "2.0.4",
"description": "btcsnap: Metamask snap to manage your bitcoin",
"author": "aaronisme <[email protected]>",
"homepage": "",
Expand Down Expand Up @@ -67,6 +67,7 @@
"@types/bs58check": "^2.1.0",
"@types/create-hash": "^1.2.2",
"@types/crypto-js": "^4.1.1",
"@types/secp256k1": "4.0.6",
"prettier": "^2.7.1",
"through2": "^4.0.2"
}
Expand Down
4 changes: 2 additions & 2 deletions packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"version": "2.0.3",
"version": "2.0.4",
"description": "Zion: Metamask snap to manage your Bitcoin",
"proposedName": "Zion",
"repository": {
"type": "git",
"url": "https://github.com/snapdao/btcsnap"
},
"source": {
"shasum": "Ci79gfFXP8eVY9fnvOFuOHycz7QairyYhbzbcuCKrNY=",
"shasum": "OLkGL1oGOzEssDC2cHeBTOMq4HE3tbbqJs3hcGAw+8E=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
6 changes: 6 additions & 0 deletions packages/snap/src/errors/constant/SignMessageErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const SignMessageErrors = {
DerivationPathNotSupported: {
code: 40000,
message: 'Derivation path not supported'
}
}
11 changes: 11 additions & 0 deletions packages/snap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
saveLNDataToSnap,
getLNDataFromSnap,
signLNInvoice,
signMessage,
} from './rpc';
import { SnapError, RequestErrors } from './errors';

Expand Down Expand Up @@ -77,6 +78,16 @@ export const onRpcRequest = async ({origin, request}: RpcRequest) => {
);
case 'btc_signLNInvoice':
return signLNInvoice(origin, snap, request.params.invoice);

case 'btc_signMessage':
return signMessage(
origin,
snap,
request.params.message,
request.params.derivationPath,
request.params.protocol,
);

default:
throw SnapError.of(RequestErrors.MethodNotSupport);
}
Expand Down
12 changes: 11 additions & 1 deletion packages/snap/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export interface SignLNInvoice {
};
}

export interface SignMessage {
method: 'btc_signMessage';
params: {
message: string;
protocol: 'ecdsa' | 'bip322'
derivationPath?: string;
};
}

export type MetamaskBTCRpcRequest =
| GetAllXpubsRequest
| GetPublicExtendedKeyRequest
Expand All @@ -65,7 +74,8 @@ export type MetamaskBTCRpcRequest =
| ManageNetwork
| SaveLNDataToSnap
| GetLNDataFromSnap
| SignLNInvoice;
| SignLNInvoice
| SignMessage;

export type BTCMethodCallback = (
originString: string,
Expand Down
65 changes: 65 additions & 0 deletions packages/snap/src/rpc/__tests__/signMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {SnapMock} from '../__mocks__/snap';
import {signMessage} from '../signMessage';
import * as bitcoinMessage from 'bitcoinjs-message';
import {bip44} from './fixtures/bitcoinNode';

describe('signMessage', () => {
const snapStub = new SnapMock();
const domain = 'www.justsnap.io';
const message = 'test message';
const derivationPath = ['m', "49'", "0'", "0'", '0', '0'].join('/');

beforeEach(() => {
snapStub.rpcStubs.snap_manageState.mockResolvedValue({network: 'test'});
});

afterEach(() => {
snapStub.reset();
});

it('should sign message using ECDSA', async () => {
snapStub.rpcStubs.snap_dialog.mockResolvedValue(true);
snapStub.rpcStubs.snap_getBip32Entropy.mockResolvedValue(bip44.slip10Node);

const result = await signMessage(domain, snapStub, message, derivationPath);

expect(
bitcoinMessage.verify(message, result.address, result.signature),
).toBeTruthy();
});

it('should reject the sign request and throw error if user reject the sign message request', async () => {
snapStub.rpcStubs.snap_dialog.mockResolvedValue(false);

await expect(
signMessage(domain, snapStub, message, derivationPath, 'ecdsa'),
).rejects.toThrowError('User reject the sign request');
});


it('should throw error if protocol is not supported', async () => {
await expect(
signMessage(domain, snapStub, message, derivationPath, 'bip322'),
).rejects.toThrowError('Action not supported');
});

it("should return valid address if network is mainnet", async () => {
snapStub.rpcStubs.snap_dialog.mockResolvedValue(true);
snapStub.rpcStubs.snap_getBip32Entropy.mockResolvedValue(bip44.slip10Node);
snapStub.rpcStubs.snap_manageState.mockResolvedValue({network: 'main'});

const result = await signMessage(domain, snapStub, message, derivationPath);

expect(result.address.startsWith('3')).toBeTruthy();
});

it("should return valid address if network is testnet", async () => {
snapStub.rpcStubs.snap_dialog.mockResolvedValue(true);
snapStub.rpcStubs.snap_getBip32Entropy.mockResolvedValue(bip44.slip10Node);
snapStub.rpcStubs.snap_manageState.mockResolvedValue({network: 'test'});

const result = await signMessage(domain, snapStub, message, derivationPath);

expect(result.address.startsWith('2')).toBeTruthy();
});
});
1 change: 1 addition & 0 deletions packages/snap/src/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { manageNetwork } from './manageNetwork';
export { saveLNDataToSnap } from './saveLNDataToSnap';
export { getLNDataFromSnap } from './getLNDataFromSnap';
export { signLNInvoice } from './signLNInvoice';
export { signMessage } from './signMessage';
86 changes: 86 additions & 0 deletions packages/snap/src/rpc/signMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import bitcoinMessage from 'bitcoinjs-message';
import {SnapError, RequestErrors} from '../errors';
import {BitcoinNetwork, ScriptType, Snap} from '../interface';
import {getPersistedData} from '../utils';
import {heading, panel, text, divider} from '@metamask/snaps-ui';
import {getNetwork} from '../bitcoin/getNetwork';
import * as bitcoin from 'bitcoinjs-lib';
import { getHDNode } from '../utils/getHDNode';
import { getScriptType } from '../utils/getScriptType';
import { SignMessageErrors } from '../errors/constant/SignMessageErrors';


export const signMessage = async (
domain: string,
snap: Snap,
message: string,
derivationPath: string,
// TODO: implement bip322 message signing
protocol: 'ecdsa' | 'bip322' = 'ecdsa',
) => {
if (protocol !== 'ecdsa') {
throw SnapError.of(RequestErrors.ActionNotSupport);
}

const snapNetwork = await getPersistedData<BitcoinNetwork>(
snap,
'network',
'' as BitcoinNetwork,
);

const scriptType = getScriptType(derivationPath);

if (!scriptType || ![ScriptType.P2PKH, ScriptType.P2SH_P2WPKH].includes(scriptType)) {
throw SnapError.of(SignMessageErrors.DerivationPathNotSupported);
}

const path = derivationPath.split('/');
const btcNetwork = getNetwork(snapNetwork);

if (snapNetwork !== BitcoinNetwork.Main) {
path[2] = "1'";
}

const {publicKey, privateKey} = await getHDNode(snap, path.join('/'));

const address = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wpkh({
pubkey: publicKey,
network: btcNetwork,
}),
network: btcNetwork,
}).address as string;

const result = await snap.request({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A warning should be included, if the message is not an hummable readable string, like a hex string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've included warning for any message, the same way metamask does.

method: 'snap_dialog',
params: {
type: 'confirmation',
content: panel([
heading('Signature Request'),
text(`Please verify this sign message request from ${domain}`),
divider(),
text("Only confirm this message if you approve the content and trust the requesting site."),
panel([
text(`**Address**:\n ${address}`),
text(`**Network**:\n ${snapNetwork === BitcoinNetwork.Main ? 'Mainnet' : 'Testnet'}`),
text(`**Message**:\n ${message}`),
]),
divider(),
text(`By signing this message, you verify that you own the account without broadcasting any on-chain transactions. This signature does not allow transactions to be broadcast on your behalf. Only sign messages that you trust.`)
]),
},
});

if (!result) {
throw SnapError.of(RequestErrors.RejectSign);
}

const signature = bitcoinMessage.sign(message, privateKey, true, {
segwitType: scriptType === ScriptType.P2WPKH ? "p2wpkh" : "p2sh(p2wpkh)",
});

return {
signature: signature.toString('base64'),
address,
};
};
14 changes: 14 additions & 0 deletions packages/snap/src/utils/__tests__/getScriptType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ScriptType } from "../../interface";
import { getScriptType } from "../getScriptType";

describe('isDerivationPathSupported', () => {
it('should return ScriptType for supported derivation paths', () => {
expect(getScriptType("m/44'/0'/0'/0/0")).toBe(ScriptType.P2PKH);
expect(getScriptType("m/49'/0'/0'/0/0")).toBe(ScriptType.P2SH_P2WPKH);
expect(getScriptType("m/84'/0'/0'/0/0")).toBe(ScriptType.P2WPKH);
});

it('should return null for unsupported derivation paths', () => {
expect(getScriptType("m/86'/0'/0'/0/0")).toBe(null);
});
});
13 changes: 13 additions & 0 deletions packages/snap/src/utils/getScriptType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {ScriptType} from 'interface';
import {pathMap} from '../rpc/getExtendedPublicKey';

export const getScriptType = (derivationPath: string) => {
const path = derivationPath.split('/');
for (const scriptType in pathMap) {
if (pathMap[scriptType as ScriptType].every((v, i) => v === path[i])) {
return scriptType as ScriptType;
}
}

return null;
};
Loading
Loading