Skip to content

Commit

Permalink
fix: migrate subdomains to wallet key address
Browse files Browse the repository at this point in the history
  • Loading branch information
ahsan-javaid authored and janniks committed Aug 2, 2022
1 parent 575b58d commit b32cb41
Show file tree
Hide file tree
Showing 5 changed files with 396 additions and 16 deletions.
32 changes: 32 additions & 0 deletions packages/cli/src/argparse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1380,6 +1380,38 @@ export const CLI_ARGS = {
'\n',
group: 'Key Management',
},
migrate_subdomains: {
type: 'array',
items: [
{
name: 'backup_phrase',
type: 'string',
realtype: '24_words_or_ciphertext',
},
{
name: 'registrar_url',
type: 'string',
realtype: 'url',
},
],
minItems: 1,
maxItems: 2,
help:
'Enable users to transfer subdomains to wallet-key addresses that correspond to all data-key addresses \n' +
'This command performs these steps in sequence: \n' +
"1. Detects whether there are any subdomains registered with the user's key and owned by data-key-derived addresses\n" +
'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' +
"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" +
'4. Initiates request to subdomain registrar using new transfer endpoint upon confirmation\n' +
'5. Displays message indicating how long the user will have to wait until request is likely fulfilled\n' +
'6. Displays confirmation that no subdomains are pending migration if user tries to execute command again\n' +
'\n' +
'Example\n' +
'\n' +
' $ 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" https://registrar.stacks.co\n' +
'\n',
group: 'Blockstack ID Management',
},
get_zonefile: {
type: 'array',
items: [
Expand Down
179 changes: 174 additions & 5 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
pubKeyfromPrivKey,
createStacksPrivateKey,
AnchorMode,
signWithKey,
} from '@stacks/transactions';
import { buildPreorderNameTx, buildRegisterNameTx } from '@stacks/bns';
import { StacksMainnet, StacksTestnet } from '@stacks/network';
Expand Down Expand Up @@ -108,10 +109,17 @@ import {
ClarityFunctionArg,
generateExplorerTxPageUrl,
isTestnetAddress,
subdomainOpToZFPieces,
SubdomainOp,
} from './utils';

import { handleAuth, handleSignIn } from './auth';
import { generateNewAccount, generateWallet, getAppPrivateKey } from '@stacks/wallet-sdk';
import {
generateNewAccount,
generateWallet,
getAppPrivateKey,
restoreWalletAccounts,
} from '@stacks/wallet-sdk';
import { getMaxIDSearchIndex, setMaxIDSearchIndex, getPrivateKeyAddress } from './common';
// global CLI options
let txOnly = false;
Expand Down Expand Up @@ -316,6 +324,165 @@ async function getStacksWalletKey(network: CLINetworkAdapter, args: string[]): P
return JSONStringify(keyInfo);
}

/**
* Enable users to transfer subdomains to wallet-key addresses that correspond to all data-key addresses
* Reference: https://github.com/hirosystems/stacks.js/issues/1209
* args:
* @mnemonic (string) the seed phrase to retrieve the privateKey & address
* @registrarUrl (string) URL of the registrar to use (defaults to 'https://registrar.stacks.co')
*/
async function migrateSubdomains(network: CLINetworkAdapter, args: string[]): Promise<string> {
const mnemonic: string = await getBackupPhrase(args[0]); // args[0] is the cli argument for mnemonic
const baseWallet = await generateWallet({ secretKey: mnemonic, password: '' });
const _network = network.isMainnet() ? new StacksMainnet() : new StacksTestnet();
const wallet = await restoreWalletAccounts({
wallet: baseWallet,
gaiaHubUrl: 'https://hub.blockstack.org',
network: _network,
});
console.log(
`Accounts found: ${wallet.accounts.length}\n(Accounts will be checked for both compressed and uncompressed public keys)`
);
const payload = { subdomains_list: <SubdomainOp[]>[] }; // Payload required by transfer endpoint

const accounts = wallet.accounts
.map(account => [
// Duplicate accounts (taking once as uncompressed, once as compressed)
{ ...account, dataPrivateKey: account.dataPrivateKey },
{ ...account, dataPrivateKey: account.dataPrivateKey + '01' },
])
.flat();

for (const account of accounts) {
console.log('\nAccount:', account);

const txVersion = network.isMainnet() ? TransactionVersion.Mainnet : TransactionVersion.Testnet;

const dataKeyAddress = getAddressFromPrivateKey(account.dataPrivateKey, txVersion); // source
const walletKeyAddress = getAddressFromPrivateKey(account.stxPrivateKey, txVersion); // target

console.log(`Finding subdomains for data-key address '${dataKeyAddress}'`);
const namesResponse = await fetch(
`${_network.coreApiUrl}/v1/addresses/stacks/${dataKeyAddress}`
);
const namesJson = await namesResponse.json();

if ((namesJson.names?.length || 0) <= 0) {
console.log(`No subdomains found for address '${dataKeyAddress}'`);
continue;
}

const regExp = /(\..*){2,}/; // has two or more dots somewhere
const subDomains = namesJson.names.filter((val: string) => regExp.test(val));

if (subDomains.length === 0) console.log(`No subdomains found for address '${dataKeyAddress}'`);

for (const subdomain of subDomains) {
// 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
const namesResponse = await fetch(
`${_network.coreApiUrl}/v1/addresses/stacks/${walletKeyAddress}`
);
const existingNames = await namesResponse.json();
if (existingNames.names?.includes(subdomain)) {
console.log(`Error: Subdomain '${subdomain}' already exists in wallet-key address.`);
continue;
}

// Validate user owns the subdomain
const nameInfo = await fetch(`${_network.coreApiUrl}/v1/names/${subdomain}`);
const nameInfoJson = await nameInfo.json();
console.log('Subdomain Info: ', nameInfoJson);
if (nameInfoJson.address !== dataKeyAddress) {
console.log(`Error: The account is not the owner of the subdomain '${subdomain}'`);
continue;
}

const promptName = subdomain.replaceAll('.', '_'); // avoid confusing with nested prompt response
const confirmMigration: { [promptName: string]: string } = await prompt([
{
name: promptName,
message: `Do you want to migrate the domain '${subdomain}'`,
type: 'confirm',
},
]);
// On 'NO', move to next account
if (!confirmMigration[promptName]) continue;

// Prepare migration operation
const [subdomainName] = subdomain.split('.'); // registrar expects only the first part of a subdomain
const subDomainOp: SubdomainOp = {
subdomainName,
owner: walletKeyAddress, // new owner address / wallet-key address (compressed)
zonefile: nameInfoJson.zonefile,
sequenceNumber: 1, // should be 'old sequence number + 1', but cannot find old sequence number so assuming 1. Api should calculate it again.
};

const subdomainPieces = subdomainOpToZFPieces(subDomainOp);
const textToSign = subdomainPieces.txt.join(',');

// Generate signature: https://docs.stacks.co/build-apps/references/bns#subdomain-lifecycle
/**
* *********************** IMPORTANT **********************************************
* If the subdomain owner wants to change the address of their subdomain, *
* they need to sign a subdomain-transfer operation and *
* give it to the on-chain name owner who created the subdomain. *
* They then package it into a zone file and broadcast it. *
* *********************** IMPORTANT **********************************************
* subdomain operation will only be accepted if it has a later "sequence=" number,*
* and a valid signature in "sig=" over the transaction body .The "sig=" field *
* includes both the public key and signature, and the public key must hash to *
* the previous subdomain operation's "addr=" field *
* ********************************************************************************
*/
const hash = crypto.createHash('sha256').update(textToSign).digest('hex');
const sig = signWithKey(createStacksPrivateKey(account.dataPrivateKey), hash);

// https://docs.stacks.co/build-apps/references/bns#subdomain-lifecycle
subDomainOp.signature = sig.data;

payload.subdomains_list.push(subDomainOp);
}
}

console.log('\nSubdomain Operation Payload:', payload);
if (payload.subdomains_list.length <= 0) {
return '"No subdomains found or selected. Canceling..."';
}

// Subdomains batch migration
// Payload contains list of subdomains that user opted for migration
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
};

// args[1] is the cli argument for registrarUrl to optionally replace default url
const registrarUrl = args[1] || 'https://registrar.stacks.co';
const migrationURL = `${registrarUrl}/transfer`;

console.log('Sending migration request...');
return fetch(migrationURL, options)
.then(response => {
if (response.status === 404) {
return Promise.reject({
status: response.status,
error: response.statusText,
});
}
return response.json();
})
.then(response => {
if (response.txid)
console.log(
`The transaction will take some time to complete. Track its progress using the explorer: https://explorer.stacks.co/txid/0x${response.txid}`
);
return Promise.resolve(JSONStringify(response));
})
.catch(error => error);
}

/*
* Make a private key and output it
* args:
Expand Down Expand Up @@ -1818,6 +1985,7 @@ const COMMANDS: Record<string, CommandFunction> = {
tx_preorder: preorder,
send_tokens: sendTokens,
stack: stack,
migrate_subdomains: migrateSubdomains,
stacking_status: stackingStatus,
faucet: faucetCall,
};
Expand Down Expand Up @@ -1856,7 +2024,7 @@ export function CLIMain() {
? parseInt(CLIOptAsString(opts, 'M')!)
: getMaxIDSearchIndex();
setMaxIDSearchIndex(maxIDSearchIndex);
const debug = CLIOptAsBool(opts, 'd');
const debug = CLIOptAsBool(opts, 'd') || Boolean(process.env.DEBUG);
const consensusHash = CLIOptAsString(opts, 'C');
const integration_test = CLIOptAsBool(opts, 'i');
const testnet = CLIOptAsBool(opts, 't');
Expand Down Expand Up @@ -1990,11 +2158,12 @@ export const testables =
process.env.NODE_ENV === 'test'
? {
addressConvert,
canStack,
contractFunctionCall,
makeKeychain,
getStacksWalletKey,
register,
makeKeychain,
migrateSubdomains,
preorder,
canStack,
register,
}
: undefined;
56 changes: 54 additions & 2 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,8 @@ type AnyJson = string | number | boolean | null | { [property: string]: AnyJson
export function JSONStringify(obj: AnyJson, stderr: boolean = false): string {
if ((!stderr && process.stdout.isTTY) || (stderr && process.stderr.isTTY)) {
return JSON.stringify(obj, null, 2);
} else {
return JSON.stringify(obj);
}
return JSON.stringify(obj);
}

/*
Expand Down Expand Up @@ -775,3 +774,56 @@ export function isTestnetAddress(address: string) {
const addressInfo = bitcoinjs.address.fromBase58Check(address);
return addressInfo.version === bitcoinjs.networks.testnet.pubKeyHash;
}

/**
* Reference: https://github.com/stacks-network/subdomain-registrar/blob/da2d144f4355bb1d67f67d1ae5f329b476d647d6/src/operations.js#L18
*/
export type SubdomainOp = {
owner: string;
sequenceNumber: number;
zonefile: string;
subdomainName: string;
signature?: string;
};

/**
* Reference: https://github.com/stacks-network/subdomain-registrar/blob/da2d144f4355bb1d67f67d1ae5f329b476d647d6/src/operations.js#L55
*/
function destructZonefile(zonefile: string) {
const encodedZonefile = Buffer.from(zonefile).toString('base64');
// we pack into 250 byte strings -- the entry "zf99=" eliminates 5 useful bytes,
// and the max is 255.
const pieces = 1 + Math.floor(encodedZonefile.length / 250);
const destructed = [];
for (let i = 0; i < pieces; i++) {
const startIndex = i * 250;
const currentPiece = encodedZonefile.slice(startIndex, startIndex + 250);
if (currentPiece.length > 0) {
destructed.push(currentPiece);
}
}
return destructed;
}

/**
* Reference: https://github.com/stacks-network/subdomain-registrar/blob/da2d144f4355bb1d67f67d1ae5f329b476d647d6/src/operations.js#L71
*/
export function subdomainOpToZFPieces(operation: SubdomainOp) {
const destructedZonefile = destructZonefile(operation.zonefile);
const txt = [
operation.subdomainName,
`owner=${operation.owner}`,
`seqn=${operation.sequenceNumber}`,
`parts=${destructedZonefile.length}`,
];
destructedZonefile.forEach((zfPart, ix) => txt.push(`zf${ix}=${zfPart}`));

if (operation.signature) {
txt.push(`sig=${operation.signature}`);
}

return {
name: operation.subdomainName,
txt,
};
}

0 comments on commit b32cb41

Please sign in to comment.