Skip to content
Open
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
131 changes: 87 additions & 44 deletions modules/sdk-coin-ton/src/ton.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import assert from 'assert';
import BigNumber from 'bignumber.js';
import * as _ from 'lodash';
import TonWeb from 'tonweb';
import {
BaseCoin,
BitGoBase,
decryptKeychainPrivateKey,
EDDSAMethods,
EDDSAUtils,
InvalidAddressError,
KeyPair,
MPCAlgorithm,
Expand Down Expand Up @@ -43,6 +46,8 @@ export interface TonParseTransactionOptions extends ParseTransactionOptions {
toAddressBounceable?: boolean;
}

type TonSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string };

export class Ton extends BaseCoin {
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
Expand Down Expand Up @@ -314,6 +319,76 @@ export class Ton extends BaseCoin {
return new TransactionBuilderFactory(coins.get(this.getChain()));
}

/**
* Discriminated union carrying keycard version and decrypted V1 user key (to avoid re-decryption).
* V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares.
*/
private async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise<TonSigningMaterial> {
const normalized = userKey.replace(/\s/g, '');
let isV1: boolean;
try {
isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, this.bitgo);
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`);
}
if (isV1) {
const userPrv = await this.decryptKeychain(normalized, walletPassphrase, 'user');
return { version: 'v1', userPrv };
}
return { version: 'v2', encryptedUserKey: normalized };
}

private async decryptKeychain(encryptedKey: string, passphrase: string, label: string): Promise<string> {
const prv = await decryptKeychainPrivateKey(this.bitgo, { encryptedPrv: encryptedKey }, passphrase);
if (!prv) {
throw new Error(`Error decrypting ${label} keychain: invalid password or corrupted key`);
}
return prv;
}

private async addRecoverySignature(
signingMaterial: TonSigningMaterial,
txBuilder: TransactionBuilder,
senderAddr: string,
unsignedTransaction: any,
currPath: string,
bitgoKey: string,
backupKey: string,
walletPassphrase: string
): Promise<void> {
if (signingMaterial.version === 'v2') {
const { userKeyShare, backupKeyShare, commonKeyChain } =
await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
signingMaterial.encryptedUserKey,
backupKey,
walletPassphrase,
this.bitgo
);
if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) {
throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey');
}
const signature = await EDDSAUtils.signRecoveryEddsaMPCv2(
unsignedTransaction.signablePayload,
currPath,
userKeyShare,
backupKeyShare,
commonKeyChain
);
txBuilder.addSignature({ pub: senderAddr } as PublicKey, signature);
} else {
const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial;
const backupPrv = await this.decryptKeychain(backupKey, walletPassphrase, 'backup');
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;
const signatureHex = await EDDSAMethods.getTSSSignature(
userSigningMaterial,
backupSigningMaterial,
currPath,
unsignedTransaction
);
txBuilder.addSignature({ pub: senderAddr } as PublicKey, signatureHex);
}
}

async recover(
params: MPCRecoveryOptions & { jettonMaster?: string; senderJettonAddress?: string }
): Promise<MPCTx | MPCSweepTxs> {
Expand Down Expand Up @@ -440,52 +515,20 @@ export class Ton extends BaseCoin {
}

if (!isUnsignedSweep) {
if (!params.userKey) {
throw new Error('missing userKey');
}
if (!params.backupKey) {
throw new Error('missing backupKey');
}
if (!params.walletPassphrase) {
throw new Error('missing wallet passphrase');
}

// Clean up whitespace from entered values
const userKey = params.userKey.replace(/\s/g, '');
const backupKey = params.backupKey.replace(/\s/g, '');

let userPrv;

try {
userPrv = await this.bitgo.decrypt({
input: userKey,
password: params.walletPassphrase,
});
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e.message}`);
}
const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial;

let backupPrv;
try {
backupPrv = await this.bitgo.decrypt({
input: backupKey,
password: params.walletPassphrase,
});
} catch (e) {
throw new Error(`Error decrypting backup keychain: ${e.message}`);
}
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;

const signatureHex = await EDDSAMethods.getTSSSignature(
userSigningMaterial,
backupSigningMaterial,
assert(params.userKey, 'missing userKey');
assert(params.backupKey, 'missing backupKey');
assert(params.walletPassphrase, 'missing wallet passphrase');
const signingMaterial = await this.isMpcV2Keycard(params.userKey, params.walletPassphrase);
await this.addRecoverySignature(
signingMaterial,
txBuilder,
senderAddr,
unsignedTransaction,
currPath,
unsignedTransaction
bitgoKey,
params.backupKey.replace(/\s/g, ''),
params.walletPassphrase
);

const publicKeyObj = { pub: senderAddr };
txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex);
}

const walletCoin = this.getChain();
Expand Down
158 changes: 153 additions & 5 deletions modules/sdk-coin-ton/test/unit/ton.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { TestBitGo } from '@bitgo/sdk-test';
import { BitGoAPI } from '@bitgo/sdk-api';
import { BitGoAPI, encrypt } from '@bitgo/sdk-api';
import { Ton, TonParseTransactionOptions, Tton } from '../../src';
import * as sinon from 'sinon';
import assert from 'assert';
import * as testData from '../resources/ton';
import { EDDSAMethods, TransactionExplanation } from '@bitgo/sdk-core';
import { EDDSAMethods, MPCRecoveryOptions, MPCTx, TransactionExplanation } from '@bitgo/sdk-core';
import should from 'should';
import utils from '../../src/lib/utils';
import Tonweb from 'tonweb';
import { MPSUtil } from '@bitgo/sdk-lib-mpc';

describe('TON:', function () {
let basecoin;
Expand Down Expand Up @@ -714,10 +715,12 @@ describe('TON:', function () {
};

sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider);
sandbox.stub(basecoin as any, 'isMpcV2Keycard').resolves({
version: 'v1',
userPrv: JSON.stringify({ dummy: 'userSigningMaterial' }),
});

const decryptStub = sandbox.stub(bitgo, 'decrypt');
decryptStub.onFirstCall().resolves(JSON.stringify({ dummy: 'userSigningMaterial' }));
decryptStub.onSecondCall().resolves(JSON.stringify({ dummy: 'backupSigningMaterial' }));
sandbox.stub(bitgo, 'decrypt').resolves(JSON.stringify({ dummy: 'backupSigningMaterial' }));

sandbox
.stub(EDDSAMethods, 'getTSSSignature')
Expand Down Expand Up @@ -808,6 +811,7 @@ describe('TON:', function () {
result.txRequests[0].should.have.property('transactions');
result.txRequests[0].transactions[0].should.have.property('unsignedTx');
result.txRequests[0].transactions[0].unsignedTx.should.equal(mockUnsignedTx);
sandbox.restore();
});

it('should take OVC output and generate a signed sweep transaction', async function () {
Expand Down Expand Up @@ -846,5 +850,149 @@ describe('TON:', function () {
recoveryTxn.transactions[0].scanIndex.should.equal(0);
recoveryTxn.lastScanIndex.should.equal(0);
});

describe('MPCv2 signed recovery', function () {
const walletPassphrase = 'test-passphrase-mpcv2';
const recoveryDestination = 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ';
const apiKey = 'db2554641c61e60a979cc6c0053f2ec91da9b13e71d287768c93c2fb556be53b';

let mpcV2UserKey: string;
let mpcV2BackupKey: string;
let mpcV2CommonKeyChain: string;
let mpcV2WalletAddress: string;
let mpcV2RecoverParams: MPCRecoveryOptions & { apiKey: string };

let mockProvider: {
getBalance: sinon.SinonStub;
getEstimateFee: sinon.SinonStub;
call: sinon.SinonStub;
send: sinon.SinonStub;
};

before(async function () {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
mpcV2CommonKeyChain = userDkg.getCommonKeychain();
mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64'));
mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64'));

const mpc = await EDDSAMethods.getInitializedMpcInstance();
const accountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64);
mpcV2WalletAddress = await utils.getAddressFromPublicKey(accountId);

mpcV2RecoverParams = {
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
recoveryDestination,
walletPassphrase,
apiKey,
};
});

beforeEach(function () {
mockProvider = {
getBalance: sandbox.stub().resolves('1000000000'),
getEstimateFee: sandbox.stub().resolves({
source_fees: { in_fwd_fee: 1000, storage_fee: 1000, gas_fee: 1000, fwd_fee: 1000 },
}),
call: sandbox.stub(),
send: sandbox.stub().callsFake((method: string) => {
if (method === 'runGetMethod') {
return Promise.resolve({ gas_used: 0, stack: [['num', '0']] });
}
return Promise.resolve({});
}),
};

sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider as any);
});

it('should recover native TON using MPCv2 signing material', async function () {
const getTSSSignatureSpy = sandbox.spy(EDDSAMethods, 'getTSSSignature');

const result = (await basecoin.recover(mpcV2RecoverParams)) as MPCTx;

result.should.not.be.empty();
result.should.hasOwnProperty('serializedTx');
result.should.hasOwnProperty('scanIndex');
should.equal(result.scanIndex, 0);
(result.serializedTx as string).should.be.a.String().and.not.be.empty();
sandbox.assert.calledWith(mockProvider.getBalance, mpcV2WalletAddress);
sandbox.assert.notCalled(getTSSSignatureSpy);
});

it('should recover jetton using MPCv2 signing material', async function () {
mockProvider.call.callsFake((address: string, method: string) => {
if (method === 'get_wallet_data') {
return Promise.resolve({
stack: [
['num', '5000000000'],
['num', '0'],
['cell', { bytes: '' }],
['cell', { bytes: '' }],
],
});
}
return Promise.resolve({ stack: [] });
});

const getTSSSignatureSpy = sandbox.spy(EDDSAMethods, 'getTSSSignature');

const jettonParams = {
...mpcV2RecoverParams,
senderJettonAddress: recoveryDestination,
};

const result = (await basecoin.recover(jettonParams)) as MPCTx;

result.should.not.be.empty();
result.should.hasOwnProperty('serializedTx');
result.should.hasOwnProperty('scanIndex');
should.equal(result.scanIndex, 0);
(result.serializedTx as string).should.be.a.String().and.not.be.empty();
sandbox.assert.calledWith(mockProvider.getBalance, mpcV2WalletAddress);
sandbox.assert.notCalled(getTSSSignatureSpy);
});

it('should use MPCv1 path when signing material is MPCv1 format', async function () {
sandbox.stub(basecoin as any, 'isMpcV2Keycard').resolves({
version: 'v1',
userPrv: JSON.stringify({ uShare: {}, bitgoYShare: {} }),
});

const getTSSSignatureStub = sandbox
.stub(EDDSAMethods, 'getTSSSignature')
.resolves(
Buffer.from(
'1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' +
'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857',
'hex'
)
);

sandbox.stub(bitgo, 'decrypt').resolves(JSON.stringify({ bShare: {}, yShares: {} }));

const result = (await basecoin.recover(mpcV2RecoverParams)) as MPCTx;

result.should.not.be.empty();
result.should.hasOwnProperty('serializedTx');
result.should.hasOwnProperty('scanIndex');
should.equal(result.scanIndex, 0);
(result.serializedTx as string).should.be.a.String().and.not.be.empty();
sandbox.assert.calledOnce(getTSSSignatureStub);
});

it('should throw when commonKeyChain from MPCv2 keycard does not match bitgoKey', async function () {
const mismatchedBitgoKey = mpcV2CommonKeyChain.slice(0, -8) + '00000000';
const mismatchedParams = {
...mpcV2RecoverParams,
bitgoKey: mismatchedBitgoKey,
};

await basecoin
.recover(mismatchedParams)
.should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey');
});
});
});
});
Loading