Skip to content

Commit 30c6d78

Browse files
authored
Merge pull request #6620 from BitGo/polyx-wrw
feat(polyx): add wrw support
2 parents 75d697e + 24ec106 commit 30c6d78

File tree

6 files changed

+519
-6
lines changed

6 files changed

+519
-6
lines changed

modules/sdk-coin-polyx/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@
4343
"@bitgo/abstract-substrate": "^1.8.9",
4444
"@bitgo/sdk-core": "^36.0.0",
4545
"@bitgo/statics": "^57.0.0",
46+
"@bitgo/sdk-lib-mpc": "^10.6.0",
4647
"@polkadot/keyring": "13.3.1",
4748
"@substrate/txwrapper-core": "7.5.2",
4849
"@substrate/txwrapper-polkadot": "7.5.2",
50+
"@polkadot/api": "14.1.1",
4951
"bignumber.js": "^9.1.2",
5052
"joi": "^17.4.0"
5153
},

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

Lines changed: 288 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,27 @@
1-
import { AuditDecryptedKeyParams, BaseCoin, BitGoBase } from '@bitgo/sdk-core';
2-
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
3-
import { SubstrateCoin } from '@bitgo/abstract-substrate';
1+
import {
2+
AuditDecryptedKeyParams,
3+
BaseCoin,
4+
BitGoBase,
5+
EDDSAMethods,
6+
EDDSAMethodTypes,
7+
MPCRecoveryOptions,
8+
MPCSweepTxs,
9+
MPCTx,
10+
MPCUnsignedTx,
11+
RecoveryTxRequest,
12+
Environments,
13+
MPCSweepRecoveryOptions,
14+
MPCTxs,
15+
} from '@bitgo/sdk-core';
16+
import { ApiPromise, WsProvider } from '@polkadot/api';
17+
import { BaseCoin as StaticsBaseCoin, coins, SubstrateSpecNameType } from '@bitgo/statics';
18+
import { KeyPair as SubstrateKeyPair, SubstrateCoin, Transaction, Interface } from '@bitgo/abstract-substrate';
419
import { BatchStakingBuilder } from './lib/batchStakingBuilder';
520
import { BondExtraBuilder } from './lib/bondExtraBuilder';
621
import { POLYX_ADDRESS_FORMAT } from './lib/constants';
22+
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
23+
import BigNumber from 'bignumber.js';
24+
import { TransactionBuilderFactory, TransferBuilder } from './lib';
725

826
export class Polyx extends SubstrateCoin {
927
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
@@ -17,10 +35,17 @@ export class Polyx extends SubstrateCoin {
1735
this._staticsCoin = staticsCoin;
1836
}
1937

38+
protected static nodeApiInitialized = false;
39+
protected static API: ApiPromise;
40+
2041
static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>): BaseCoin {
2142
return new Polyx(bitgo, staticsCoin);
2243
}
2344

45+
getBuilder(): TransactionBuilderFactory {
46+
return new TransactionBuilderFactory(coins.get(this.getChain()));
47+
}
48+
2449
/**
2550
* Factor between the coin's base unit and its smallest subdivison
2651
*/
@@ -58,4 +83,264 @@ export class Polyx extends SubstrateCoin {
5883
protected getAddressFormat(): number {
5984
return POLYX_ADDRESS_FORMAT;
6085
}
86+
87+
protected async getInitializedNodeAPI(): Promise<ApiPromise> {
88+
if (!Polyx.nodeApiInitialized) {
89+
const wsProvider = new WsProvider(Environments[this.bitgo.getEnv()].polymeshNodeUrls);
90+
Polyx.API = await ApiPromise.create({ provider: wsProvider });
91+
Polyx.nodeApiInitialized = true;
92+
}
93+
return Polyx.API;
94+
}
95+
96+
protected async getAccountInfo(walletAddr: string): Promise<{ nonce: number; freeBalance: number }> {
97+
const api = await this.getInitializedNodeAPI();
98+
const { nonce, data: balance } = await api.query.system.account(walletAddr);
99+
100+
return { nonce: nonce.toNumber(), freeBalance: balance.free.toNumber() };
101+
}
102+
103+
protected async getFee(destAddr: string, srcAddr: string, amount: number): Promise<number> {
104+
const api = await this.getInitializedNodeAPI();
105+
const info = await api.tx.balances.transfer(destAddr, amount).paymentInfo(srcAddr);
106+
return info.partialFee.toNumber();
107+
}
108+
109+
protected async getHeaderInfo(): Promise<{ headerNumber: number; headerHash: string }> {
110+
const api = await this.getInitializedNodeAPI();
111+
const { number, hash } = await api.rpc.chain.getHeader();
112+
return { headerNumber: number.toNumber(), headerHash: hash.toString() };
113+
}
114+
115+
protected async getMaterial(): Promise<Interface.Material> {
116+
const api = await this.getInitializedNodeAPI();
117+
return {
118+
genesisHash: api.genesisHash.toString(),
119+
chainName: api.runtimeChain.toString(),
120+
specName: api.runtimeVersion.specName.toString() as SubstrateSpecNameType,
121+
specVersion: api.runtimeVersion.specVersion.toNumber(),
122+
txVersion: api.runtimeVersion.transactionVersion.toNumber(),
123+
metadata: api.runtimeMetadata.toHex(),
124+
};
125+
}
126+
127+
/**
128+
* Builds a funds recovery transaction without BitGo
129+
* @param {MPCRecoveryOptions} params parameters needed to construct and
130+
* (maybe) sign the transaction
131+
*
132+
* @returns {MPCTx} the serialized transaction hex string and index
133+
* of the address being swept
134+
*/
135+
async recover(params: MPCRecoveryOptions): Promise<MPCTx | MPCSweepTxs> {
136+
if (!params.bitgoKey) {
137+
throw new Error('Missing bitgoKey');
138+
}
139+
140+
if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) {
141+
throw new Error('Invalid recovery destination address');
142+
}
143+
144+
const bitgoKey = params.bitgoKey.replace(/\s/g, '');
145+
const isUnsignedSweep = !params.userKey && !params.backupKey && !params.walletPassphrase;
146+
147+
const MPC = await EDDSAMethods.getInitializedMpcInstance();
148+
149+
const index = params.index || 0;
150+
const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`;
151+
const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64);
152+
const senderAddr = this.getAddressFromPublicKey(accountId);
153+
154+
const { nonce, freeBalance } = await this.getAccountInfo(senderAddr);
155+
156+
const destAddr = params.recoveryDestination;
157+
const amount = freeBalance;
158+
const partialFee = await this.getFee(destAddr, senderAddr, amount);
159+
const paddedFee = new BigNumber(partialFee).times(10).toNumber();
160+
const amountToSend = new BigNumber(amount).minus(new BigNumber(paddedFee));
161+
162+
const value = new BigNumber(freeBalance).minus(new BigNumber(partialFee));
163+
if (value.isLessThanOrEqualTo(0)) {
164+
throw new Error('Did not find address with funds to recover');
165+
}
166+
167+
const { headerNumber, headerHash } = await this.getHeaderInfo();
168+
const material = await this.getMaterial();
169+
const validityWindow = { firstValid: headerNumber, maxDuration: this.MAX_VALIDITY_DURATION };
170+
171+
const txBuilder = this.getBuilder().getTransferBuilder().material(material) as TransferBuilder;
172+
173+
txBuilder
174+
.amount(amountToSend.toString())
175+
.to({ address: params.recoveryDestination })
176+
.sender({ address: senderAddr })
177+
.memo('0')
178+
.validity(validityWindow)
179+
.referenceBlock(headerHash)
180+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: nonce })
181+
.fee({ amount: 0, type: 'tip' });
182+
183+
const unsignedTransaction = (await txBuilder.build()) as Transaction;
184+
185+
let serializedTx = unsignedTransaction.toBroadcastFormat();
186+
if (!isUnsignedSweep) {
187+
if (!params.userKey) {
188+
throw new Error('missing userKey');
189+
}
190+
if (!params.backupKey) {
191+
throw new Error('missing backupKey');
192+
}
193+
if (!params.walletPassphrase) {
194+
throw new Error('missing wallet passphrase');
195+
}
196+
197+
const userKey = params.userKey.replace(/\s/g, '');
198+
const backupKey = params.backupKey.replace(/\s/g, '');
199+
200+
// Decrypt private keys from KeyCard values
201+
let userPrv;
202+
try {
203+
userPrv = this.bitgo.decrypt({
204+
input: userKey,
205+
password: params.walletPassphrase,
206+
});
207+
} catch (e) {
208+
throw new Error(`Error decrypting user keychain: ${e.message}`);
209+
}
210+
const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial;
211+
212+
let backupPrv;
213+
try {
214+
backupPrv = this.bitgo.decrypt({
215+
input: backupKey,
216+
password: params.walletPassphrase,
217+
});
218+
} catch (e) {
219+
throw new Error(`Error decrypting backup keychain: ${e.message}`);
220+
}
221+
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;
222+
223+
// add signature
224+
const signatureHex = await EDDSAMethods.getTSSSignature(
225+
userSigningMaterial,
226+
backupSigningMaterial,
227+
currPath,
228+
unsignedTransaction
229+
);
230+
231+
const substrateKeyPair = new SubstrateKeyPair({ pub: accountId });
232+
txBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, signatureHex);
233+
const signedTransaction = await txBuilder.build();
234+
serializedTx = signedTransaction.toBroadcastFormat();
235+
} else {
236+
const walletCoin = this.getChain();
237+
const inputs = [
238+
{
239+
address: unsignedTransaction.inputs[0].address,
240+
valueString: amountToSend.toString(),
241+
value: amountToSend.toNumber(),
242+
},
243+
];
244+
const outputs = [
245+
{
246+
address: unsignedTransaction.outputs[0].address,
247+
valueString: amountToSend.toString(),
248+
coinName: walletCoin,
249+
},
250+
];
251+
const spendAmount = amountToSend.toString();
252+
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' };
253+
const feeInfo = { fee: 0, feeString: '0' };
254+
const transaction: MPCTx = {
255+
serializedTx: serializedTx,
256+
scanIndex: index,
257+
coin: walletCoin,
258+
signableHex: unsignedTransaction.signablePayload.toString('hex'),
259+
derivationPath: currPath,
260+
parsedTx: parsedTx,
261+
feeInfo: feeInfo,
262+
coinSpecific: { ...validityWindow, commonKeychain: bitgoKey },
263+
};
264+
265+
const unsignedTx: MPCUnsignedTx = { unsignedTx: transaction, signatureShares: [] };
266+
const transactions: MPCUnsignedTx[] = [unsignedTx];
267+
const txRequest: RecoveryTxRequest = {
268+
transactions: transactions,
269+
walletCoin: walletCoin,
270+
};
271+
const txRequests: MPCSweepTxs = { txRequests: [txRequest] };
272+
return txRequests;
273+
}
274+
275+
const transaction: MPCTx = { serializedTx: serializedTx, scanIndex: index };
276+
return transaction;
277+
}
278+
279+
/** inherited doc */
280+
async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise<MPCTxs> {
281+
const req = params.signatureShares;
282+
const broadcastableTransactions: MPCTx[] = [];
283+
let lastScanIndex = 0;
284+
285+
for (let i = 0; i < req.length; i++) {
286+
const MPC = await EDDSAMethods.getInitializedMpcInstance();
287+
const transaction = req[i].txRequest.transactions[0].unsignedTx;
288+
if (!req[i].ovc || !req[i].ovc[0].eddsaSignature) {
289+
throw new Error('Missing signature(s)');
290+
}
291+
const signature = req[i].ovc[0].eddsaSignature;
292+
if (!transaction.signableHex) {
293+
throw new Error('Missing signable hex');
294+
}
295+
const messageBuffer = Buffer.from(transaction.signableHex!, 'hex');
296+
const result = MPC.verify(messageBuffer, signature);
297+
if (!result) {
298+
throw new Error('Invalid signature');
299+
}
300+
const signatureHex = Buffer.concat([Buffer.from(signature.R, 'hex'), Buffer.from(signature.sigma, 'hex')]);
301+
if (
302+
!transaction.coinSpecific ||
303+
!transaction.coinSpecific?.firstValid ||
304+
!transaction.coinSpecific?.maxDuration
305+
) {
306+
throw new Error('missing validity window');
307+
}
308+
const validityWindow = {
309+
firstValid: transaction.coinSpecific?.firstValid,
310+
maxDuration: transaction.coinSpecific?.maxDuration,
311+
};
312+
const material = await this.getMaterial();
313+
if (!transaction.coinSpecific?.commonKeychain) {
314+
throw new Error('Missing common keychain');
315+
}
316+
const commonKeychain = transaction.coinSpecific!.commonKeychain! as string;
317+
if (!transaction.derivationPath) {
318+
throw new Error('Missing derivation path');
319+
}
320+
const derivationPath = transaction.derivationPath as string;
321+
const accountId = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64);
322+
const senderAddr = this.getAddressFromPublicKey(accountId);
323+
324+
const txnBuilder = this.getBuilder()
325+
.material(material)
326+
.from(transaction.serializedTx as string)
327+
.sender({ address: senderAddr })
328+
.validity(validityWindow);
329+
330+
const substrateKeyPair = new SubstrateKeyPair({ pub: accountId });
331+
txnBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, signatureHex);
332+
const signedTransaction = await txnBuilder.build();
333+
const serializedTx = signedTransaction.toBroadcastFormat();
334+
335+
broadcastableTransactions.push({
336+
serializedTx: serializedTx,
337+
scanIndex: transaction.scanIndex,
338+
});
339+
340+
if (i === req.length - 1 && transaction.coinSpecific!.lastScanIndex) {
341+
lastScanIndex = transaction.coinSpecific!.lastScanIndex as number;
342+
}
343+
}
344+
return { transactions: broadcastableTransactions, lastScanIndex };
345+
}
61346
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
export const wrwUser = {
2+
userKey:
3+
'{"iv":"TR25BHPM0F8OrlhC5UEnUA==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' +
4+
':"ccm","adata":"","cipher":"aes","salt":"uQfN/R/hYaM=","ct":"IzM4WznffhakZk\n' +
5+
'KwsRxTEcQ7PIcr5f6ro2YXbn1mP/dlMaYlmGOE1fJemchVeuFUWLGphJPMaUz3v4lJbklvRGtTP\n' +
6+
'F8+JeFSp/ps++wirzlZoAmPOGrOPRg7TkOJXR7xZyBJHeUoV9xu7X1iKGrxhYr70CrzlkrYqlQR\n' +
7+
'qU8NiV1YbeY+Ra56m9RfeE2HaXk2s9xNzn8mfdOdjkd1sjXwTzv3WPTBWPD35H8EiDbLuVLLOBg\n' +
8+
'tVPNahBimxffWE5B6keQTN3ezKNTwE0z3B4sBTGHb8wbumhRAO42/BV7MNbvCDPe+GfkqljMu13\n' +
9+
'u9PAaRb3Y/nK1szIxqN4PpUYP92UYr/QjveuHk37+wxQhljfn81jhkhtGOvnv1EaBhZNJ0PWbrf\n' +
10+
'E9Xprx8vLmyy8UiGayUvXka2bEZyPksjBpD7chhKbqCj3q5EhJQLUiydLr9/BV50seM1+AQ9w+R\n' +
11+
'8KjybUgwIF/qTLcaCxVHeAKRzs6LMGj43+l1kTkdaGRre+6MAK2aExZK2FJer49v8htKo7Z6vy+\n' +
12+
'149DBwTnvCGEDQVXuumKCGX0CCfzbLAMZVKB/Xqdd3e9B99QbVFmPQpeAlJXrOpaoXS90xSSBKs\n' +
13+
'dbr1wkRHlYB/NvQZwl2sGfIcPJASfWKLFUSXJM7ql1y89llNNSW7RUBcf9tKK5SqmVyGxWZ+1LJ\n' +
14+
'a5ZEh4BRYh5Kx4IleCQNyZI73XVZE8kyHo07TWwlI8jR8DUIsuF1cBmFRKn2XoikQrDYie/o1zS\n' +
15+
'1hDMzvF4C9BeacW6iPZ1n9/0OkwgiUIDJGh4vIOei/9U/N2scc8z5+F/yaXXW7COKxqKkx74tqI\n' +
16+
'ohPm0CTnH2t2cqjK+C5x3FdwiPT+uIzgSU04EZP4gzngZ11yunj7cvw80x9/BK+GwbM97haJ8Je\n' +
17+
'lMDg5++m6fJlb6weW1Rd6IiUfRSXpio7KQbxZ04UG5uPROAY1xbCJbGQi9OjcyXQKQtL81WkHSr\n' +
18+
'LGjN/jXCts7OH3xZRaZk66DP6X3LaT0b6qmlem2Uvf+ZIKResF3ivijLYopJWfftamaFDyY+463\n' +
19+
'uI/WNFWrc7iQWI4Fy3eUezuqBZhiZdWMxuP7yDwLgWr2h3CUNYoLvocrRuye6cI8fjmDWzeTdun\n' +
20+
'5tSM8OL30hY7aH1n/ZDweng0w5k8i7Znm5LpLaAJzkA7CFV0CXI56s3isJ5CjXJFm"}',
21+
backupKey:
22+
'{"iv":"yoGM9gzwedwMp+D5xfZjuw==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' +
23+
':"ccm","adata":"","cipher":"aes","salt":"bLoJpDYNb1o=","ct":"rK3JZ5VyxqsfgV\n' +
24+
'JVrR8bddfMe5cy963ntRt3B7l71tvHT8KleNBlzPN6VPrvw1T9KhTHPZ8zDq1pLN39wdrgr4lD4\n' +
25+
'7+FwsclqQscsi97YW7pTHhWBGGMCKzItXQfeXYCZqvXu/BeV80VWKwso40vZ13pNWmZRhjy/40U\n' +
26+
'Z0J/Tf+2NuFSEuLKznrgD/oLJaAkYS7d1Bt+GUHWkXnDOsLwqbBL8y5TQJkZrp37nDXXRdP1IE1\n' +
27+
'MOm2C+GMvboHwm4bpKQRpVURgjyk/7/XWXQ7OUvIFgq324sQbQCHVB/voaRVF+3x7ShRjiGG9sE\n' +
28+
'W0+XsbbamrXvGiexAGPDM+2kuPe/rBFhJgf4HJnYFAM9sjzWdOIedhkuOokwqgd5OqreVMO/dYF\n' +
29+
'PGI29cnrov8JpIQw5gArtlgUxOdelg/oCaRUryHCABVPTODbFYygf+3IAgng2M+oEvh26gVFOfz\n' +
30+
'oPfBoqqXk6/kWpc3zx2byOB+c6Yzhg9sRnF5UobMBl50nklo1kM/24ObmsNCQeVyuZARjQgpbZx\n' +
31+
'wn5l51knvxmvQXxvwnfsuifje9GkE4Tt+vYu49o3V8vmRaOxcEMnzj5WOC/MOb6QPT4Jx8/J9CB\n' +
32+
'V4nU+MvgZbDJKMx9kuyJ62PihBy8uSE1JrNgWU3aEkuh9aD5HEoAtyy4tBatRTzOMLSsSr3hii7\n' +
33+
'pVTBkfEFVzsrHRfIc4FhsnFqSG1yPtxQ68FUeZNM23pCOSJMcbzuP2deEwOCNE5AFIhVZmHg5Yn\n' +
34+
'CXgcUwAHh+aD+Jv8LMyHq0U6GKkpghu5bptUJcX6oOwUoKD04NWuE6VvK0ZJhSpxxOLeLI4YrwA\n' +
35+
'tNVo9KAehWPHJfbx4XyBKu+3Z/mIfPJ2rxz7Izdci/BhDKiuHRUynUJ7/8Taxkjqa6RBdFD/avm\n' +
36+
'Z1a8GyGlxYbnF3OlJUS0Op3lAXBygMyfZqDYbYSfr7ufaIfULAApxXIV0+tyKeF78yAH/k4GIvH\n' +
37+
'8GzkFu0MSzhWDddeUuKY4Me36GmPHfkzXw+jGt/I6R2vPZKW5RsfMooV7QKUrINtQTZ/4b8jAsr\n' +
38+
'NJyYZX1wmjwQu3UzqpN+4oPh+WOJUX1Weo5B6c/4e1FmchSiPcI9xCR64MddbFaA/ORdN4p4cgL\n' +
39+
'B4K3MOqMpoOCMgo/+N9JQr5FTMrcxVSJWRm7QrSGP3go2tywH1UuJOHxhAthdRw=="}',
40+
bitgoKey:
41+
'74372039ccd6c9b2e1e16e3258ff3307b05527ac5a34e1b603b7b74353599c5eb6de9fc5f0f\n' +
42+
'5f677bfa1f41915d9e1a2e794f8d5d9770d219bf5432adf0415e3',
43+
walletPassphrase: '#Bondiola1234',
44+
walletAddress: '5C8nEaqzwMLC2EFjUFckCfPLeFUVWNp3FErQiN8D5u2ZkWyt',
45+
};
46+
47+
export const unsignedSweepUser = {
48+
userKey:
49+
'97d9da8bf544d07f3c7ae8ee06bb12a63f74478d0c461201ab94421a9c81d5b379553f0332d\n' +
50+
'aaa85cb8c65e1fe7e6907c62268c00812b08a4e1361178e304ddb',
51+
backupKey:
52+
'97d9da8bf544d07f3c7ae8ee06bb12a63f74478d0c461201ab94421a9c81d5b379553f0332d\n' +
53+
'aaa85cb8c65e1fe7e6907c62268c00812b08a4e1361178e304ddb',
54+
bitgoKey:
55+
'97d9da8bf544d07f3c7ae8ee06bb12a63f74478d0c461201ab94421a9c81d5b379553f0332d\n' +
56+
'aaa85cb8c65e1fe7e6907c62268c00812b08a4e1361178e304ddb',
57+
walletPassphrase: '#Bondiola1234',
58+
walletAddress: '5DnZQqbxtB3CWkLgfA6wpqdkCd9Bq7AX49RpLPJwW9mzoT7s',
59+
};
60+
61+
export const testnetBlock = {
62+
blockNumber: 12640277,
63+
hash: '0x3771101969cc5cf1b37db0bdce589fe9174a1b820341c4d3673ce301352a3d42',
64+
};

0 commit comments

Comments
 (0)