Skip to content

Commit 44d5eb0

Browse files
feat(sdk-coin-near): add unsigned sweep recovery for near
TICKET: WIN-4973
1 parent e8c3e36 commit 44d5eb0

File tree

3 files changed

+244
-21
lines changed

3 files changed

+244
-21
lines changed

modules/sdk-coin-near/src/near.ts

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import {
2626
MPCAlgorithm,
2727
EDDSAMethods,
2828
EDDSAMethodTypes,
29+
MPCTx,
30+
MPCUnsignedTx,
31+
RecoveryTxRequest,
32+
MPCSweepTxs,
33+
MPCSweepRecoveryOptions,
34+
MPCTxs,
2935
MultisigType,
3036
multisigTypes,
3137
} from '@bitgo/sdk-core';
@@ -85,11 +91,6 @@ interface RecoveryOptions {
8591
scan?: number;
8692
}
8793

88-
interface NearTx {
89-
serializedTx: string;
90-
scanIndex: number;
91-
}
92-
9394
interface NearTxBuilderParamsFromNode {
9495
nonce: number;
9596
blockHash: string;
@@ -327,7 +328,7 @@ export class Near extends BaseCoin {
327328
* Builds a funds recovery transaction without BitGo
328329
* @param params
329330
*/
330-
async recover(params: RecoveryOptions): Promise<NearTx> {
331+
async recover(params: RecoveryOptions): Promise<MPCTx | MPCSweepTxs> {
331332
if (!params.bitgoKey) {
332333
throw new Error('missing bitgoKey');
333334
}
@@ -397,9 +398,9 @@ export class Near extends BaseCoin {
397398
.receiverId(params.recoveryDestination)
398399
.recentBlockHash(blockHash)
399400
.amount(netAmount.toFixed());
400-
401+
const unsignedTransaction = (await txBuilder.build()) as Transaction;
402+
let serializedTx = unsignedTransaction.toBroadcastFormat();
401403
if (!isUnsignedSweep) {
402-
const unsignedTransaction = (await txBuilder.build()) as Transaction;
403404
// Sign the txn
404405
/* ***************** START **************************************/
405406
// TODO(BG-51092): This looks like a common part which can be extracted out too
@@ -451,15 +452,120 @@ export class Near extends BaseCoin {
451452
);
452453
const publicKeyObj = { pub: accountId };
453454
txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex);
454-
}
455455

456-
const completedTransaction = await txBuilder.build();
457-
const serializedTx = completedTransaction.toBroadcastFormat();
456+
const completedTransaction = await txBuilder.build();
457+
serializedTx = completedTransaction.toBroadcastFormat();
458+
} else {
459+
const value = new BigNumber(netAmount); // Use the calculated netAmount for the transaction
460+
const walletCoin = this.getChain();
461+
const inputs = [
462+
{
463+
address: accountId, // The sender's account ID
464+
valueString: value.toString(),
465+
value: value.toNumber(),
466+
},
467+
];
468+
const outputs = [
469+
{
470+
address: params.recoveryDestination, // The recovery destination address
471+
valueString: value.toString(),
472+
coinName: walletCoin,
473+
},
474+
];
475+
const spendAmount = value.toString();
476+
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' };
477+
const feeInfo = { fee: totalGasWithPadding.toNumber(), feeString: totalGasWithPadding.toFixed() }; // Include gas fees
478+
479+
const transaction: MPCTx = {
480+
serializedTx: serializedTx, // Serialized unsigned transaction
481+
scanIndex: i, // Current index in the scan
482+
coin: walletCoin,
483+
signableHex: unsignedTransaction.signablePayload.toString('hex'), // Hex payload for signing
484+
derivationPath: currPath, // Derivation path for the account
485+
parsedTx: parsedTx,
486+
feeInfo: feeInfo,
487+
coinSpecific: { commonKeychain: bitgoKey }, // Include block hash for NEAR
488+
};
489+
490+
const transactions: MPCUnsignedTx[] = [{ unsignedTx: transaction, signatureShares: [] }];
491+
const txRequest: RecoveryTxRequest = {
492+
transactions: transactions,
493+
walletCoin: walletCoin,
494+
};
495+
return { txRequests: [txRequest] };
496+
}
458497
return { serializedTx: serializedTx, scanIndex: i };
459498
}
460499
throw new Error('Did not find an address with funds to recover');
461500
}
462501

502+
async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise<MPCTxs> {
503+
const req = params.signatureShares;
504+
const broadcastableTransactions: MPCTx[] = [];
505+
let lastScanIndex = 0;
506+
507+
for (let i = 0; i < req.length; i++) {
508+
const MPC = await EDDSAMethods.getInitializedMpcInstance();
509+
const transaction = req[i].txRequest.transactions[0].unsignedTx;
510+
511+
// Validate signature shares
512+
if (!req[i].ovc || !req[i].ovc[0].eddsaSignature) {
513+
throw new Error('Missing signature(s)');
514+
}
515+
const signature = req[i].ovc[0].eddsaSignature;
516+
517+
// Validate signable hex
518+
if (!transaction.signableHex) {
519+
throw new Error('Missing signable hex');
520+
}
521+
const messageBuffer = Buffer.from(transaction.signableHex!, 'hex');
522+
const result = MPC.verify(messageBuffer, signature);
523+
if (!result) {
524+
throw new Error('Invalid signature');
525+
}
526+
527+
// Prepare the signature in hex format
528+
const signatureHex = Buffer.concat([Buffer.from(signature.R, 'hex'), Buffer.from(signature.sigma, 'hex')]);
529+
530+
// Validate transaction-specific fields
531+
if (!transaction.coinSpecific?.commonKeychain) {
532+
throw new Error('Missing common keychain');
533+
}
534+
const commonKeychain = transaction.coinSpecific!.commonKeychain! as string;
535+
536+
if (!transaction.derivationPath) {
537+
throw new Error('Missing derivation path');
538+
}
539+
const derivationPath = transaction.derivationPath as string;
540+
541+
// Derive account ID and sender address
542+
const accountId = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64);
543+
const txnBuilder = this.getBuilder().from(transaction.serializedTx as string);
544+
545+
// Add the signature
546+
const nearKeyPair = new NearKeyPair({ pub: accountId });
547+
txnBuilder.addSignature({ pub: nearKeyPair.getKeys().pub }, signatureHex);
548+
549+
// Finalize and serialize the transaction
550+
const signedTransaction = await txnBuilder.build();
551+
const serializedTx = signedTransaction.toBroadcastFormat();
552+
553+
// Add the signed transaction to the list
554+
broadcastableTransactions.push({
555+
serializedTx: serializedTx,
556+
scanIndex: transaction.scanIndex,
557+
});
558+
559+
// Update the last scan index if applicable
560+
if (i === req.length - 1 && transaction.coinSpecific!.lastScanIndex) {
561+
lastScanIndex = transaction.coinSpecific!.lastScanIndex as number;
562+
}
563+
}
564+
565+
// Return the broadcastable transactions and the last scan index
566+
return { transactions: broadcastableTransactions, lastScanIndex };
567+
}
568+
463569
/**
464570
* Make a request to one of the public EOS nodes available
465571
* @param params.payload

modules/sdk-coin-near/test/fixtures/near.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,72 @@ const getGasPriceResponse = {
346346
id: 'dontcare',
347347
},
348348
};
349+
export const ovcResponse = {
350+
signatureShares: [
351+
{
352+
txRequest: {
353+
transactions: [
354+
{
355+
unsignedTx: {
356+
serializedTx:
357+
'QAAAAGIzODNjYWM2ZjNjZDY0OTViZDZhYjg3NzMwMGE4NzliN2RiYzRhMTZhYjBlZjE5NzlkZTZmNzNkYjAyNDlmYWEAs4PKxvPNZJW9arh3MAqHm328SharDvGXneb3PbAkn6oBuZUj6a0AAEAAAABlYWRiMzIwOGZiOWU5MWY2MGQ3NmUzYzUxNzEzZDA1Y2I0YTU5NDFlNWYzNTVlMWZmOThlMTQwYTcxMjNlODRl2hbJtC4rwLyWAbMzTgTcRmr5xpWlrXOXbzxMWcP7wwcBAAAAA9A1oVfvpz3o4hcAAAAAAAA=',
358+
scanIndex: 0,
359+
coin: 'tnear',
360+
signableHex: '9ce890db77fe8b62478e22bee84387b77e110c21cbba8fe3ee7eb4bf953c6e2c',
361+
derivationPath: 'm/0',
362+
parsedTx: {
363+
inputs: [
364+
{
365+
address: 'b383cac6f3cd6495bd6ab877300a879b7dbc4a16ab0ef1979de6f73db0249faa',
366+
valueString: '1.12800127983096986416592e+23',
367+
value: 1.12800127983097e23,
368+
},
369+
],
370+
outputs: [
371+
{
372+
address: 'eadb3208fb9e91f60d76e3c51713d05cb4a5941e5f355e1ff98e140a7123e84e',
373+
valueString: '1.12800127983096986416592e+23',
374+
coinName: 'tnear',
375+
},
376+
],
377+
spendAmount: '1.12800127983096986416592e+23',
378+
type: '',
379+
},
380+
feeInfo: {
381+
fee: 6.862863796875e19,
382+
feeString: '68628637968750000000',
383+
},
384+
coinSpecific: {
385+
commonKeychain:
386+
'23f6ac586f0c7fe1ba4e67af674c06e61ea8d88b3c0243d5cbf6f66b0077ec807307ad0da02f62bfeec1e603df8305d72c49d1c9c1e99808b71fbeb1d8c85e0c',
387+
lastScanIndex: 0,
388+
},
389+
},
390+
signatureShares: [],
391+
signatureShare: {
392+
from: 'backup',
393+
to: 'user',
394+
share:
395+
'bc02163a3d9cd5086a6d6702959f1d5bb29071f1bd8181648a66d10f223cb7c21ce535876b1560f78eaaa7f7cf3f35885ed539bcd0345d8db5edae402770bf04',
396+
publicShare: 'b383cac6f3cd6495bd6ab877300a879b7dbc4a16ab0ef1979de6f73db0249faa',
397+
},
398+
},
399+
],
400+
walletCoin: 'tnear',
401+
},
402+
tssVersion: '0.0.1',
403+
ovc: [
404+
{
405+
eddsaSignature: {
406+
y: 'b383cac6f3cd6495bd6ab877300a879b7dbc4a16ab0ef1979de6f73db0249faa',
407+
R: 'bc02163a3d9cd5086a6d6702959f1d5bb29071f1bd8181648a66d10f223cb7c2',
408+
sigma: 'f84d4894c915cc62fe54f23684b0d156b5e05c506a2515c2d049b6c3e4c0a90f',
409+
},
410+
},
411+
],
412+
},
413+
],
414+
};
349415

350416
export const NearResponses = {
351417
getAccessKeyResponse,

modules/sdk-coin-near/test/unit/near.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
keys,
1212
accountInfo,
1313
nonce,
14+
ovcResponse,
1415
} from '../fixtures/near';
1516
import * as _ from 'lodash';
1617
import * as sinon from 'sinon';
@@ -742,17 +743,67 @@ describe('NEAR:', function () {
742743
bitgoKey: keys.bitgoKey,
743744
recoveryDestination: 'abhay-near.testnet',
744745
});
745-
res.should.not.be.empty();
746-
res.should.hasOwnProperty('serializedTx');
747746

748-
const UnsignedSweepTxnDeserialize = new Transaction(coin);
749-
UnsignedSweepTxnDeserialize.fromRawTransaction(res.serializedTx);
750-
const UnsignedSweepTxnJson = UnsignedSweepTxnDeserialize.toJson();
751-
752-
should.equal(UnsignedSweepTxnJson.nonce, nonce);
753-
should.equal(UnsignedSweepTxnJson.signerId, accountInfo.accountId);
754-
should.equal(UnsignedSweepTxnJson.publicKey, 'ed25519:' + accountInfo.bs58EncodedPublicKey);
755-
sandBox.assert.callCount(basecoin.getDataFromNode, 4);
747+
// Assertions for the structure of the result
748+
should.exist(res);
749+
res.should.have.property('txRequests').which.is.an.Array();
750+
res.txRequests[0].should.have.property('transactions').which.is.an.Array();
751+
res.txRequests[0].transactions[0].should.have.property('unsignedTx');
752+
753+
// Assertions for the unsigned transaction
754+
const unsignedTx = res.txRequests[0].transactions[0].unsignedTx;
755+
unsignedTx.should.have.property('serializedTx').which.is.a.String();
756+
unsignedTx.should.have.property('scanIndex', 0);
757+
unsignedTx.should.have.property('coin', 'tnear');
758+
unsignedTx.should.have.property(
759+
'signableHex',
760+
'c27d684b6f09c4b603d9bf8a08baedf12b8bb951f314acd747b16bb75cfbf687'
761+
);
762+
unsignedTx.should.have.property('derivationPath', 'm/0');
763+
764+
// Assertions for parsed transaction
765+
const parsedTx = unsignedTx.parsedTx;
766+
parsedTx.should.have.property('inputs').which.is.an.Array();
767+
parsedTx.inputs[0].should.have.property(
768+
'address',
769+
'f256196dae617aa348149c1e61e997272492668d517506d7a6e2392e06ea532c'
770+
);
771+
parsedTx.inputs[0].should.have.property('valueString', '1.97885506094866269650000001e+26');
772+
parsedTx.inputs[0].should.have.property('value', 1.9788550609486627e26);
773+
774+
parsedTx.should.have.property('outputs').which.is.an.Array();
775+
parsedTx.outputs[0].should.have.property('address', 'abhay-near.testnet');
776+
parsedTx.outputs[0].should.have.property('valueString', '1.97885506094866269650000001e+26');
777+
parsedTx.outputs[0].should.have.property('coinName', 'tnear');
778+
779+
parsedTx.should.have.property('spendAmount', '1.97885506094866269650000001e+26');
780+
parsedTx.should.have.property('type', '');
781+
782+
// Assertions for fee info
783+
unsignedTx.should.have.property('feeInfo');
784+
unsignedTx.feeInfo.should.have.property('fee', 68628637968750000000);
785+
unsignedTx.feeInfo.should.have.property('feeString', '68628637968750000000');
786+
787+
// Assertions for coin-specific data
788+
unsignedTx.should.have.property('coinSpecific');
789+
unsignedTx.coinSpecific.should.have.property(
790+
'commonKeychain',
791+
'8699d2e05d60a3f7ab733a74ccf707f3407494b60f4253616187f5262e20737519a1763de0bcc4d165a7fa0e4dde67a1426ec4cc9fcd0820d749e6589dcfa08e'
792+
);
793+
});
794+
795+
it('should take OVC output and generate a signed sweep transaction for NEAR', async function () {
796+
const params = ovcResponse; // NEAR-specific response fixture
797+
const recoveryTxn = await basecoin.createBroadcastableSweepTransaction(params);
798+
799+
// Validate the serialized transaction
800+
recoveryTxn.transactions[0].serializedTx.should.equal(
801+
'QAAAAGIzODNjYWM2ZjNjZDY0OTViZDZhYjg3NzMwMGE4NzliN2RiYzRhMTZhYjBlZjE5NzlkZTZmNzNkYjAyNDlmYWEAs4PKxvPNZJW9arh3MAqHm328SharDvGXneb3PbAkn6oBuZUj6a0AAEAAAABlYWRiMzIwOGZiOWU5MWY2MGQ3NmUzYzUxNzEzZDA1Y2I0YTU5NDFlNWYzNTVlMWZmOThlMTQwYTcxMjNlODRl2hbJtC4rwLyWAbMzTgTcRmr5xpWlrXOXbzxMWcP7wwcBAAAAA9A1oVfvpz3o4hcAAAAAAAAAvAIWOj2c1QhqbWcClZ8dW7KQcfG9gYFkimbRDyI8t8L4TUiUyRXMYv5U8jaEsNFWteBcUGolFcLQSbbD5MCpDw=='
802+
);
803+
804+
// Validate the scan index
805+
recoveryTxn.transactions[0].scanIndex.should.equal(0);
806+
recoveryTxn.lastScanIndex.should.equal(0);
756807
});
757808
});
758809

0 commit comments

Comments
 (0)