Skip to content
Closed
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
118 changes: 111 additions & 7 deletions modules/sdk-coin-near/src/near.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getEddsaSigningMaterial as sharedGetEddsaSigningMaterial,
KeyPair,
MPCAlgorithm,
MPCConsolidationRecoveryOptions,
MPCRecoveryOptions,
MPCSweepRecoveryOptions,
MPCSweepTxs,
Expand Down Expand Up @@ -347,8 +348,10 @@ export class Near extends BaseCoin {
/**
* Builds a funds recovery transaction without BitGo
* @param params
* @param {EddsaSigningMaterial} [precomputedMaterial] signing material detected once by the
* caller (e.g. recoverConsolidations) to avoid re-decrypting the keycard on every loop iteration
*/
async recover(params: MPCRecoveryOptions): Promise<MPCTx | MPCSweepTxs> {
async recover(params: MPCRecoveryOptions, precomputedMaterial?: EddsaSigningMaterial): Promise<MPCTx | MPCSweepTxs> {
if (!params.bitgoKey) {
throw new Error('missing bitgoKey');
}
Expand Down Expand Up @@ -449,7 +452,8 @@ export class Near extends BaseCoin {
bitgoKey,
isStorageDepositEnabled,
availableTokenBalance,
isUnsignedSweep
isUnsignedSweep,
precomputedMaterial
);
}

Expand Down Expand Up @@ -483,7 +487,7 @@ export class Near extends BaseCoin {
const unsignedTransaction = (await txBuilder.build()) as Transaction;
let serializedTx = unsignedTransaction.toBroadcastFormat();
if (!isUnsignedSweep) {
serializedTx = await this.signRecoveryTransaction(txBuilder, params, currPath, accountId);
serializedTx = await this.signRecoveryTransaction(txBuilder, params, currPath, accountId, precomputedMaterial);
} else {
return this.buildUnsignedSweepTransaction(
txBuilder,
Expand All @@ -501,6 +505,97 @@ export class Near extends BaseCoin {
throw new Error('Did not find an address with funds to recover');
}

/**
* Consolidates funds from multiple receive addresses to the base address (index 0).
* If walletPassphrase is not provided, returns unsigned transactions for offline signing
* (cold/custody wallet recovery). Otherwise, returns signed transactions.
*
* @param params - Consolidation recovery parameters
* @param params.bitgoKey - The commonKeychain (combined TSS public key)
* @param params.startingScanIndex - Starting address index to scan (default: 1)
* @param params.endingScanIndex - Ending address index to scan (default: startingScanIndex + 20)
* @param params.walletPassphrase - Optional passphrase for signing (omit for unsigned transactions)
* @returns MPCTxs (signed) or MPCSweepTxs (unsigned) containing all consolidation transactions
* @throws Error if no addresses with funds are found in the scan range
*/
async recoverConsolidations(params: MPCConsolidationRecoveryOptions): Promise<MPCTxs | MPCSweepTxs> {
const isUnsignedSweep = !params.walletPassphrase;

const startIdx = params.startingScanIndex ?? 1;
if (!Number.isInteger(startIdx) || startIdx < 1) {
throw new Error('Invalid starting index to scan for addresses');
}
const endIdx = params.endingScanIndex ?? startIdx + 20;
if (!Number.isInteger(endIdx) || endIdx <= startIdx || endIdx - startIdx > 200) {
throw new Error(
`Invalid starting or ending index to scan for addresses. startingScanIndex: ${startIdx}, endingScanIndex: ${endIdx}.`
);
}

const bitgoKey = params.bitgoKey.replace(/\s/g, '');
const userKey = params.userKey?.replace(/\s/g, '');

// Detect signing material once to avoid re-decrypting the keycard on every loop iteration.
const signingMaterial =
userKey && params.walletPassphrase
? await this.getEddsaSigningMaterial(userKey, params.walletPassphrase)
: undefined;

const MPC = await EDDSAMethods.getInitializedMpcInstance();
const baseAccountId = MPC.deriveUnhardened(bitgoKey, 'm/0').slice(0, 64);

const consolidationTransactions: Array<MPCTx | RecoveryTxRequest> = [];
let lastScanIndex = startIdx;

for (let idx = startIdx; idx < endIdx; idx++) {
const recoverParams: MPCRecoveryOptions = {
userKey: params.userKey,
backupKey: params.backupKey,
bitgoKey: params.bitgoKey,
walletPassphrase: params.walletPassphrase,
seed: params.seed,
tokenContractAddress: params.tokenContractAddress,
recoveryDestination: baseAccountId, // Consolidate to base address
startingScanIndex: idx,
scan: 1,
};

let recoveryTransaction: MPCTx | MPCSweepTxs;
try {
recoveryTransaction = await this.recover(recoverParams, signingMaterial);
} catch (e) {
if ((e as Error).message.startsWith('Did not find an address with funds to recover')) {
lastScanIndex = idx;
continue;
}
throw e;
}

if (isUnsignedSweep) {
consolidationTransactions.push((recoveryTransaction as MPCSweepTxs).txRequests[0]);
} else {
consolidationTransactions.push(recoveryTransaction as MPCTx);
}
lastScanIndex = idx;
}

if (consolidationTransactions.length === 0) {
throw new Error(
`Did not find an address with sufficient funds to recover. Please start the next scan at address index ${
lastScanIndex + 1
}.`
);
}

if (isUnsignedSweep) {
const txRequests = consolidationTransactions as RecoveryTxRequest[];
txRequests[txRequests.length - 1].transactions[0].unsignedTx.coinSpecific!.lastScanIndex = lastScanIndex;
return { txRequests };
}

return { transactions: consolidationTransactions as MPCTx[], lastScanIndex };
}

/**
* Function to handle near token recovery
* @param {MPCRecoveryOptions} params mpc recovery options input
Expand All @@ -523,7 +618,8 @@ export class Near extends BaseCoin {
bitgoKey: string,
isStorageDepositEnabled: boolean,
availableTokenBalance: BigNumber,
isUnsignedSweep: boolean
isUnsignedSweep: boolean,
precomputedMaterial?: EddsaSigningMaterial
): Promise<MPCTx | MPCSweepTxs> {
const factory = new TransactionBuilderFactory(token);
const bs58EncodedPublicKey = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(senderAddress, 'hex')));
Expand Down Expand Up @@ -558,7 +654,13 @@ export class Near extends BaseCoin {
token
);
} else {
const serializedTx = await this.signRecoveryTransaction(txBuilder, params, derivationPath, senderAddress);
const serializedTx = await this.signRecoveryTransaction(
txBuilder,
params,
derivationPath,
senderAddress,
precomputedMaterial
);
return { serializedTx: serializedTx, scanIndex: idx };
}
}
Expand Down Expand Up @@ -656,7 +758,8 @@ export class Near extends BaseCoin {
txBuilder: TransactionBuilder,
params: MPCRecoveryOptions,
derivationPath: string,
senderAddress: string
senderAddress: string,
precomputedMaterial?: EddsaSigningMaterial
): Promise<string> {
const unsignedTransaction = (await txBuilder.build()) as Transaction;

Expand All @@ -673,7 +776,8 @@ export class Near extends BaseCoin {
const backupKey = params.backupKey.replace(/\s/g, '');
const bitgoKey = params.bitgoKey.replace(/\s/g, '');

const signingMaterial = await this.getEddsaSigningMaterial(params.userKey, params.walletPassphrase);
const signingMaterial =
precomputedMaterial ?? (await this.getEddsaSigningMaterial(params.userKey, params.walletPassphrase));

let signatureHex: Buffer;
if (signingMaterial.version === 'v2') {
Expand Down
202 changes: 202 additions & 0 deletions modules/sdk-coin-near/test/unit/near.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1901,4 +1901,206 @@ describe('NEAR:', function () {
isValidSignature.should.be.true();
});
});

describe('Recover Consolidations (MPCv2):', () => {
const consolidationSandbox = sinon.createSandbox();
const walletPassphrase = 'test-passphrase-mpcv2-consolidation';
let callBack: sinon.SinonStub;
let mpcV2UserKey: string;
let mpcV2BackupKey: string;
let mpcV2CommonKeyChain: string;
let baseAccountId: string;
let address1: string;
let address2: string;
let bs58Address1: string;
let bs58Address2: string;
const coin = coins.get('tnear');

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

const mpc = await EDDSAMethods.getInitializedMpcInstance();
baseAccountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64);
address1 = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/1').slice(0, 64);
address2 = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/2').slice(0, 64);
bs58Address1 = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(address1, 'hex')));
bs58Address2 = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(address2, 'hex')));
});

beforeEach(() => {
callBack = consolidationSandbox.stub(Near.prototype, 'getDataFromNode' as keyof Near);
callBack.withArgs().resolves(NearResponses.getProtocolConfigResp);
callBack
.withArgs({
payload: {
jsonrpc: '2.0',
id: 'dontcare',
method: 'gas_price',
params: [accountInfo.blockHash],
},
})
.resolves(NearResponses.getGasPriceResponse);

for (const [addr, bs58] of [
[address1, bs58Address1],
[address2, bs58Address2],
]) {
callBack
.withArgs({
payload: {
jsonrpc: '2.0',
id: 'dontcare',
method: 'query',
params: {
request_type: 'view_access_key',
finality: 'final',
account_id: addr,
public_key: bs58,
},
},
})
.resolves(NearResponses.getAccessKeyResponse);
callBack
.withArgs({
payload: {
jsonrpc: '2.0',
id: 'dontcare',
method: 'query',
params: {
request_type: 'view_account',
finality: 'final',
account_id: addr,
},
},
})
.resolves(NearResponses.getAccountResponse);
}
});

afterEach(() => {
consolidationSandbox.restore();
});

it('should detect MPCv2 signing material once and sweep two recoveries to the base address', async function () {
const getEddsaSigningMaterialSpy = consolidationSandbox.spy(
Near.prototype as unknown as { getEddsaSigningMaterial: unknown },
'getEddsaSigningMaterial'
);

const res = (await basecoin.recoverConsolidations({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
walletPassphrase,
startingScanIndex: 1,
endingScanIndex: 3,
})) as { transactions: Array<{ scanIndex: number; serializedTx: string }> };

res.transactions.length.should.equal(2);
res.transactions[0].scanIndex.should.equal(1);
res.transactions[1].scanIndex.should.equal(2);

const recovered1 = new Transaction(coin);
recovered1.fromRawTransaction(res.transactions[0].serializedTx);
recovered1.toJson().receiverId.should.equal(baseAccountId);

const recovered2 = new Transaction(coin);
recovered2.fromRawTransaction(res.transactions[1].serializedTx);
recovered2.toJson().receiverId.should.equal(baseAccountId);

// Detected once up front in recoverConsolidations(), not per scanned index.
consolidationSandbox.assert.calledOnce(getEddsaSigningMaterialSpy);
});

it('should return unsigned MPCv2 consolidation sweeps when walletPassphrase is absent', async function () {
const res = (await basecoin.recoverConsolidations({
bitgoKey: mpcV2CommonKeyChain,
startingScanIndex: 1,
endingScanIndex: 3,
})) as { txRequests: Array<{ transactions: Array<{ unsignedTx: { scanIndex: number } }> }> };

res.txRequests.length.should.equal(2);
res.txRequests[0].transactions[0].unsignedTx.scanIndex.should.equal(1);
res.txRequests[1].transactions[0].unsignedTx.scanIndex.should.equal(2);
});
});

describe('Recover Consolidations (MPCv1 regression):', () => {
const sandBox = sinon.createSandbox();
const coin = coins.get('tnear');
const address1Info = {
accountId: 'f6842bf4a8e980704fbd9fb799bfbe0a116fd5d8d06f6774e792c68c907d9b20',
bs58EncodedPublicKey: 'HbJBqyagBqtSNUR74fLMQSjQ8HyQVs66fyMySPhZLXz7',
blockHash: '844N9aWefd4TvJwdiBgXDVPz4W9z436kohTiXnp5y4fq',
};

beforeEach(function () {
const callBack = sandBox.stub(Near.prototype, 'getDataFromNode' as keyof Near);
callBack
.withArgs({
payload: {
jsonrpc: '2.0',
id: 'dontcare',
method: 'query',
params: {
request_type: 'view_access_key',
finality: 'final',
account_id: address1Info.accountId,
public_key: address1Info.bs58EncodedPublicKey,
},
},
})
.resolves(NearResponses.getAccessKeyResponse);
callBack
.withArgs({
payload: {
jsonrpc: '2.0',
id: 'dontcare',
method: 'query',
params: {
request_type: 'view_account',
finality: 'final',
account_id: address1Info.accountId,
},
},
})
.resolves(NearResponses.getAccountResponse);
callBack.withArgs().resolves(NearResponses.getProtocolConfigResp);
callBack
.withArgs({
payload: {
jsonrpc: '2.0',
id: 'dontcare',
method: 'gas_price',
params: [address1Info.blockHash],
},
})
.resolves(NearResponses.getGasPriceResponse);
});

afterEach(function () {
sandBox.restore();
});

it('should sweep an MPCv1 signed consolidation to the legacy-derived base address, unchanged', async function () {
const res = (await basecoin.recoverConsolidations({
userKey: keys.userKey,
backupKey: keys.backupKey,
bitgoKey: keys.bitgoKey,
walletPassphrase: 'Ghghjkg!455544llll',
startingScanIndex: 1,
endingScanIndex: 2,
})) as { transactions: Array<{ scanIndex: number; serializedTx: string }> };

res.transactions.length.should.equal(1);
res.transactions[0].scanIndex.should.equal(1);

const recovered = new Transaction(coin);
recovered.fromRawTransaction(res.transactions[0].serializedTx);
recovered.toJson().receiverId.should.equal(accountInfo.accountId);
});
});
});
Loading