Skip to content

Commit 0962077

Browse files
committed
fix: migrate subdomains to wallet key address
1 parent 265b483 commit 0962077

File tree

5 files changed

+366
-3
lines changed

5 files changed

+366
-3
lines changed

packages/cli/src/argparse.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,33 @@ export const CLI_ARGS = {
13801380
'\n',
13811381
group: 'Key Management',
13821382
},
1383+
migrate_subdomains: {
1384+
type: 'array',
1385+
items: [
1386+
{
1387+
name: 'backup_phrase',
1388+
type: 'string',
1389+
realtype: '24_words_or_ciphertext',
1390+
},
1391+
],
1392+
minItems: 1,
1393+
maxItems: 1,
1394+
help:
1395+
'Enable users to transfer subdomains to wallet-key addresses that correspond to all data-key addresses \n' +
1396+
'This command performs these steps in sequence: \n' +
1397+
"1. Detects whether there are any subdomains registered with the user's key and owned by data-key-derived addresses\n" +
1398+
'2. Prompts the user to confirm whether they want to migrate these subdomains to the corresponding wallet-key-derived addresses for their key by position\n' +
1399+
"3. Alerts the user to any subdomains that can't be migrated to these wallet-key-derived addresses given collision with existing usernames owned by them\n" +
1400+
'4. Initiates request to subdomain registrar using new transfer endpoint upon confirmation\n' +
1401+
'5. Displays message indicating how long the user will have to wait until request is likely fulfilled\n' +
1402+
'6. Displays confirmation that no subdomains are pending migration if user tries to execute command again\n' +
1403+
'\n' +
1404+
'Example\n' +
1405+
'\n' +
1406+
' $ stx migrate_subdomains "toast canal educate tissue express melody produce later gospel victory meadow outdoor hollow catch liberty annual gasp hat hello april equip thank neck cruise"\n' +
1407+
'\n',
1408+
group: 'Blockstack ID Management',
1409+
},
13831410
get_zonefile: {
13841411
type: 'array',
13851412
items: [

packages/cli/src/cli.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import {
4242
pubKeyfromPrivKey,
4343
createStacksPrivateKey,
4444
AnchorMode,
45+
signWithKey,
46+
compressPublicKey,
4547
} from '@stacks/transactions';
4648
import { buildPreorderNameTx, buildRegisterNameTx } from '@stacks/bns';
4749
import { StacksMainnet, StacksTestnet } from '@stacks/network';
@@ -109,10 +111,17 @@ import {
109111
ClarityFunctionArg,
110112
generateExplorerTxPageUrl,
111113
isTestnetAddress,
114+
subdomainOpToZFPieces,
115+
SubdomainOp,
112116
} from './utils';
113117

114118
import { handleAuth, handleSignIn } from './auth';
115-
import { generateNewAccount, generateWallet, getAppPrivateKey } from '@stacks/wallet-sdk';
119+
import {
120+
generateNewAccount,
121+
generateWallet,
122+
getAppPrivateKey,
123+
restoreWalletAccounts,
124+
} from '@stacks/wallet-sdk';
116125
import { getMaxIDSearchIndex, setMaxIDSearchIndex, getPrivateKeyAddress } from './common';
117126
// global CLI options
118127
let txOnly = false;
@@ -317,6 +326,163 @@ async function getStacksWalletKey(network: CLINetworkAdapter, args: string[]): P
317326
return JSONStringify(keyInfo);
318327
}
319328

329+
/**
330+
* Enable users to transfer subdomains to wallet-key addresses that correspond to all data-key addresses
331+
* Reference: https://github.com/hirosystems/stacks.js/issues/1209
332+
* args:
333+
* @mnemonic (string) the 12-word phrase to retrieve the privateKey & address
334+
*/
335+
async function migrateSubdomains(network: CLINetworkAdapter, args: string[]): Promise<string> {
336+
// Decrypt the mnemonic input given by user
337+
const mnemonic: string = await getBackupPhrase(args[0]); // args[0] is the cli argument for mnemonic
338+
// Get baseWallet from mnemonic given by user
339+
const baseWallet = await generateWallet({ secretKey: mnemonic, password: '' });
340+
// Find network type to find user accounts
341+
const _network = network.isMainnet() ? new StacksMainnet() : new StacksTestnet();
342+
// fetch list of accounts stored in a json file on Gaia
343+
const wallet = await restoreWalletAccounts({
344+
wallet: baseWallet,
345+
gaiaHubUrl: 'https://hub.blockstack.org',
346+
network: _network,
347+
});
348+
// Inform user about retrieved accounts
349+
console.log(`Accounts found: ${wallet.accounts.length}`);
350+
// Use this status instance and return at the end of command completion
351+
const status = { found: 0, migrated: 0, collisions: 0, pending: 0 };
352+
// Loop over accounts and check if there are any subdomain linked with data key address
353+
for (let i = 0; i < wallet.accounts.length; i++) {
354+
const account = wallet.accounts[i];
355+
console.log('Index: ', i, 'Account: ', account);
356+
// Get data key address from data private key
357+
const dataKeyAddress = getAddressFromPrivateKey(
358+
account.dataPrivateKey,
359+
network.isMainnet() ? TransactionVersion.Mainnet : TransactionVersion.Testnet
360+
);
361+
// Get wallet key address from stx private key
362+
const walletKeyAddress = getAddressFromPrivateKey(
363+
account.stxPrivateKey,
364+
network.isMainnet() ? TransactionVersion.Mainnet : TransactionVersion.Testnet
365+
);
366+
// Find subdomains at dataKeyAddress for the account in iteration
367+
console.log('Finding subdomains at data key address: ', dataKeyAddress);
368+
const namesResponse = await fetch(
369+
`${_network.coreApiUrl}/v1/addresses/stacks/${dataKeyAddress}`
370+
);
371+
const namesJson = await namesResponse.json();
372+
373+
const regExp = /(\..*){2,}/; // regex to verify two dots in subdomain
374+
375+
if ((namesJson.names.length || 0) > 0) {
376+
// Filter only subdomains
377+
const subDomains = namesJson.names.filter((val: string) => regExp.test(val));
378+
379+
if (subDomains.length === 0) console.log('No subdomains found at: ', dataKeyAddress);
380+
381+
for (const subDomain of subDomains) {
382+
status.found++;
383+
// Alerts the user to any subdomains that can't be migrated to these wallet-key-derived addresses
384+
// Given collision with existing usernames owned by them
385+
const namesResponse = await fetch(
386+
`${_network.coreApiUrl}/v1/addresses/stacks/${walletKeyAddress}`
387+
);
388+
const existingNames = await namesResponse.json();
389+
if (existingNames.names.indexOf(subDomain) >= 0) {
390+
status.collisions++;
391+
console.log(`collision: ${subDomain} already exists in wallet key address.`);
392+
continue;
393+
}
394+
// validate user owns the subdomain
395+
const nameInfo = await fetch(`${_network.coreApiUrl}/v1/names/${subDomain}`);
396+
const nameInfoJson = await nameInfo.json();
397+
console.log('subdomain info: ', nameInfoJson);
398+
if (nameInfoJson.address !== dataKeyAddress) {
399+
console.log(`Error: Only owner of ${subDomain} can execute this migration`);
400+
continue; // Skip and move to next subdomain in iteration
401+
}
402+
// Remove . char in prompt name to avoid nested prompt response
403+
const promptName = subDomain.replaceAll('.', '_');
404+
// Prompt user if he want the subdomain migration
405+
const confirm: { [promptName: string]: string } = await prompt([
406+
{
407+
name: promptName,
408+
message: `Do you want to migrate the domain: ${subDomain}`,
409+
type: 'confirm',
410+
},
411+
]);
412+
// If no, then move to next account
413+
if (!confirm[promptName]) continue;
414+
415+
// Generate Subdomain Operation payload starting with signature
416+
417+
const subDomainOp: SubdomainOp = {
418+
subdomainName: subDomain, // Subdomain name
419+
owner: walletKeyAddress, // New owner address / wallet key address
420+
zonefile: nameInfoJson.zonefile, // Old copy of zonefile at data key address
421+
sequenceNumber: 1,
422+
// It should be (old sequence number + 1) but cannot find old sequence number so assuming 1. Api should calc it again.
423+
};
424+
425+
const subdomainPieces = subdomainOpToZFPieces(subDomainOp);
426+
427+
const textToSign = subdomainPieces.txt.join(',');
428+
429+
// Generate signature: https://docs.stacks.co/build-apps/references/bns#subdomain-lifecycle
430+
/**
431+
* *********************** IMPORTANT **********************************************
432+
* If the subdomain owner wants to change the address of their subdomain, *
433+
* they need to sign a subdomain-transfer operation and *
434+
* give it to the on-chain name owner who created the subdomain. *
435+
* They then package it into a zone file and broadcast it. *
436+
* *********************** IMPORTANT **********************************************
437+
* subdomain operation will only be accepted if it has a later "sequence=" number,*
438+
* and a valid signature in "sig=" over the transaction body .The "sig=" field *
439+
* includes both the public key and signature, and the public key must hash to *
440+
* the previous subdomain operation's "addr=" field *
441+
* ********************************************************************************
442+
*/
443+
444+
const hash = crypto.createHash('sha256').update(textToSign).digest('hex');
445+
const sig = signWithKey(createStacksPrivateKey(account.dataPrivateKey), hash);
446+
// https://docs.stacks.co/build-apps/references/bns#subdomain-lifecycle
447+
subDomainOp.signature = [
448+
sig.data,
449+
compressPublicKey(
450+
publicKeyToString(pubKeyfromPrivKey(account.dataPrivateKey))
451+
).data.toString('hex'),
452+
].join(',');
453+
console.log('SubDomain Operation payload', subDomainOp);
454+
// Todo: // This is a supposed migration api request
455+
const options = {
456+
method: 'POST',
457+
headers: { 'Content-Type': 'application/json' },
458+
body: JSON.stringify(subDomainOp),
459+
};
460+
461+
const migrationURL = 'https://registrar.stacks.co/migrate-subdomain'; // Todo: // Required API call
462+
463+
const response = await fetch(migrationURL, options);
464+
465+
if (response.ok) {
466+
const migrationStatus = await response.json();
467+
status.migrated++;
468+
console.log(migrationStatus);
469+
console.log(
470+
'Transaction will take some time to complete. Check transaction explorer url to verify the status of migration'
471+
);
472+
// Continue to next subDomain or address in loop
473+
} else {
474+
status.pending++;
475+
console.log(response);
476+
throw Error(`Failed to migrate: ${response.statusText}`);
477+
}
478+
}
479+
} else {
480+
console.log('No subdomains found at: ', dataKeyAddress);
481+
}
482+
}
483+
return JSONStringify(status);
484+
}
485+
320486
/*
321487
* Make a private key and output it
322488
* args:
@@ -1836,6 +2002,7 @@ const COMMANDS: Record<string, CommandFunction> = {
18362002
tx_preorder: preorder,
18372003
send_tokens: sendTokens,
18382004
stack: stack,
2005+
migrate_subdomains: migrateSubdomains,
18392006
stacking_status: stackingStatus,
18402007
faucet: faucetCall,
18412008
};
@@ -2013,5 +2180,6 @@ export const testables =
20132180
getStacksWalletKey,
20142181
register,
20152182
preorder,
2183+
migrateSubdomains,
20162184
}
20172185
: undefined;

packages/cli/src/utils.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,3 +775,56 @@ export function isTestnetAddress(address: string) {
775775
const addressInfo = bitcoinjs.address.fromBase58Check(address);
776776
return addressInfo.version === bitcoinjs.networks.testnet.pubKeyHash;
777777
}
778+
779+
/**
780+
* Reference: https://github.com/stacks-network/subdomain-registrar/blob/da2d144f4355bb1d67f67d1ae5f329b476d647d6/src/operations.js#L18
781+
*/
782+
export type SubdomainOp = {
783+
owner: string;
784+
sequenceNumber: number;
785+
zonefile: string;
786+
subdomainName: string;
787+
signature?: string;
788+
};
789+
790+
/**
791+
* Reference: https://github.com/stacks-network/subdomain-registrar/blob/da2d144f4355bb1d67f67d1ae5f329b476d647d6/src/operations.js#L55
792+
*/
793+
function destructZonefile(zonefile: string) {
794+
const encodedZonefile = Buffer.from(zonefile).toString('base64');
795+
// we pack into 250 byte strings -- the entry "zf99=" eliminates 5 useful bytes,
796+
// and the max is 255.
797+
const pieces = 1 + Math.floor(encodedZonefile.length / 250);
798+
const destructed = [];
799+
for (let i = 0; i < pieces; i++) {
800+
const startIndex = i * 250;
801+
const currentPiece = encodedZonefile.slice(startIndex, startIndex + 250);
802+
if (currentPiece.length > 0) {
803+
destructed.push(currentPiece);
804+
}
805+
}
806+
return destructed;
807+
}
808+
809+
/**
810+
* Reference: https://github.com/stacks-network/subdomain-registrar/blob/da2d144f4355bb1d67f67d1ae5f329b476d647d6/src/operations.js#L71
811+
*/
812+
export function subdomainOpToZFPieces(operation: SubdomainOp) {
813+
const destructedZonefile = destructZonefile(operation.zonefile);
814+
const txt = [
815+
operation.subdomainName,
816+
`owner=${operation.owner}`,
817+
`seqn=${operation.sequenceNumber}`,
818+
`parts=${destructedZonefile.length}`,
819+
];
820+
destructedZonefile.forEach((zfPart, ix) => txt.push(`zf${ix}=${zfPart}`));
821+
822+
if (operation.signature) {
823+
txt.push(`sig=${operation.signature}`);
824+
}
825+
826+
return {
827+
name: operation.subdomainName,
828+
txt,
829+
};
830+
}

0 commit comments

Comments
 (0)