diff --git a/package-lock.json b/package-lock.json index dc15f16d4..c9af0e921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23885,6 +23885,7 @@ "version": "6.12.1", "license": "MIT", "dependencies": { + "@noble/hashes": "1.1.5", "@scure/base": "1.1.1", "@stacks/common": "^6.10.0", "@stacks/encryption": "^6.12.1", diff --git a/packages/stacking/README.md b/packages/stacking/README.md index 112d912fe..ef6605f57 100644 --- a/packages/stacking/README.md +++ b/packages/stacking/README.md @@ -94,13 +94,27 @@ const privateKey = 'd48f215481c16cbe6426f8e557df9b78895661971d71735126545abddcd5 // block height at which to stack const burnBlockHeight = 2000; +// signer key +const signerPrivateKey = makeRandomPrivKey(); +const signerKey = getPublicKeyFromPrivate(signerPrivateKey.data); + // Refer to initialization section to create client instance +const signerSignature = client.signPoxSignature({ + topic: 'stack-stx', + rewardCycle: await client.getPoxInfo().reward_cycle_id, + poxAddress, + period: cycles, + signerPrivateKey, +}); + const stackingResults = await client.stack({ amountMicroStx, poxAddress, cycles, privateKey, burnBlockHeight, + signerKey, + signerSignature, }); // { @@ -521,7 +535,7 @@ const delegetateCommitResponse = await poolClient.stackAggregationCommitIndexed( #### Increase existing commitment -The result of this commit transaction will contain the index of the pools reward set entry. +Increase partially stacked STX via the index of the reward set entry. ```typescript // reward cycle id to commit to diff --git a/packages/stacking/package.json b/packages/stacking/package.json index f65e6f4cc..6accebb36 100644 --- a/packages/stacking/package.json +++ b/packages/stacking/package.json @@ -20,6 +20,7 @@ "typecheck:watch": "npm run typecheck -- --watch" }, "dependencies": { + "@noble/hashes": "1.1.5", "@scure/base": "1.1.1", "@stacks/common": "^6.10.0", "@stacks/encryption": "^6.12.1", diff --git a/packages/stacking/src/index.ts b/packages/stacking/src/index.ts index 6ffb25db6..32b96dda6 100644 --- a/packages/stacking/src/index.ts +++ b/packages/stacking/src/index.ts @@ -1,5 +1,4 @@ -// @ts-ignore -import { IntegerType, intToBigInt } from '@stacks/common'; +import { IntegerType, bytesToHex, hexToBytes, intToBigInt } from '@stacks/common'; import { StacksNetwork } from '@stacks/network'; import { BurnchainRewardListResponse, @@ -16,11 +15,13 @@ import { OptionalCV, PrincipalCV, ResponseErrorCV, + StacksPrivateKey, StacksTransaction, TupleCV, TxBroadcastResult, UIntCV, broadcastTransaction, + bufferCV, callReadOnlyFunction, cvToString, getFee, @@ -34,9 +35,12 @@ import { } from '@stacks/transactions'; import { PoxOperationPeriod, StackingErrors } from './constants'; import { + Pox4SignatureTopic, ensureLegacyBtcAddressForPox1, ensurePox2Activated, + ensureSignerArgsReadiness, poxAddressToTuple, + signPox4SignatureHash, unwrap, unwrapMap, } from './utils'; @@ -68,15 +72,13 @@ export interface ContractVersion { export interface PoxInfo { contract_id: string; - contract_versions?: ContractVersion[]; + contract_versions: ContractVersion[]; current_burnchain_block_height?: number; first_burnchain_block_height: number; min_amount_ustx: string; next_reward_cycle_in: number; prepare_cycle_length: number; prepare_phase_block_length: number; - rejection_fraction: number; - rejection_votes_left_required: number; reward_cycle_id: number; reward_cycle_length: number; reward_phase_block_length: number; @@ -92,24 +94,15 @@ export interface PoxInfo { }; } -export type PoxOperationInfo = - | { - period: PoxOperationPeriod.Period1; - pox1: { contract_id: string }; - } - | { - period: PoxOperationPeriod; - pox1: { contract_id: string }; - pox2: ContractVersion; - current: ContractVersion; - } - | { - period: PoxOperationPeriod.Period3; - pox1: { contract_id: string }; - pox2: ContractVersion; - pox3: ContractVersion; - current: ContractVersion; - }; +export type PoxOperationInfo = { + /** @deprecated Period isn't needed anymore after 2.1 fork went live */ + period: PoxOperationPeriod; + pox1: { contract_id: string }; + pox2: ContractVersion; + pox3: ContractVersion; + pox4: ContractVersion; + current: ContractVersion; +}; export interface AccountExtendedBalances { stx: { @@ -140,6 +133,7 @@ export type StackerInfo = version: Uint8Array; hashbytes: Uint8Array; }; + signer_key?: string; }; }; @@ -233,6 +227,14 @@ export interface LockStxOptions { amountMicroStx: IntegerType; /** the burnchain block height to begin lock */ burnBlockHeight: number; + /** hex-encoded signer key `(buff 33)`, required for >= PoX-4 */ + signerKey?: string; + /** hex-encoded signature `(buff 65)`, required for >= PoX-4 */ + signerSignature?: string; + /** Maximum amount of STX that can be locked in this transaction, required for >= PoX-4 */ + maxAmount?: IntegerType; + /** Random integer to prevent re-use of signer signature, required for >= PoX-4 */ + authId?: IntegerType; } /** @@ -245,6 +247,14 @@ export interface StackExtendOptions { extendCycles: number; /** the reward Bitcoin address */ poxAddress: string; + /** hex-encoded signer key `(buff 33)`, required for >= PoX-4 */ + signerKey?: string; + /** hex-encoded signature `(buff 65)`, required for >= PoX-4 */ + signerSignature?: string; + /** Maximum amount of STX that can be locked in this transaction, required for >= PoX-4 */ + maxAmount?: IntegerType; + /** Random integer to prevent re-use of signer signature, required for >= PoX-4 */ + authId?: IntegerType; } /** @@ -255,6 +265,14 @@ export interface StackIncreaseOptions { privateKey: string; /** number of ustx to increase by */ increaseBy: IntegerType; + /** hex-encoded signer key `(buff 33)`, required for >= PoX-4 */ + signerKey?: string; + /** hex-encoded signature `(buff 65)`, required for >= PoX-4 */ + signerSignature?: string; + /** Maximum amount of STX that can be locked in this transaction, required for >= PoX-4 */ + maxAmount?: IntegerType; + /** Random integer to prevent re-use of signer signature, required for >= PoX-4 */ + authId?: IntegerType; } /** @@ -277,6 +295,8 @@ export interface DelegateStxOptions { * Delegate stack stx options */ export interface DelegateStackStxOptions { + /** private key to sign transaction */ + privateKey: string; /** the STX address of the delegator */ stacker: string; /** number of microstacks to lock */ @@ -287,8 +307,6 @@ export interface DelegateStackStxOptions { burnBlockHeight: number; /** number of cycles to lock */ cycles: number; - /** private key to sign transaction */ - privateKey: string; } /** @@ -325,6 +343,14 @@ export interface StackAggregationCommitOptions { poxAddress: string; rewardCycle: number; privateKey: string; + /** hex-encoded signer key `(buff 33)`, required for >= PoX-4 */ + signerKey?: string; + /** hex-encoded signature `(buff 65)`, required for >= PoX-4 */ + signerSignature?: string; + /** Maximum amount of STX that can be locked in this transaction, required for >= PoX-4 */ + maxAmount?: IntegerType; + /** Random integer to prevent re-use of signer signature, required for >= PoX-4 */ + authId?: IntegerType; } export interface StackAggregationIncreaseOptions { @@ -534,77 +560,18 @@ export class StackingClient { async getPoxOperationInfo(poxInfo?: PoxInfo): Promise { poxInfo = poxInfo ?? (await this.getPoxInfo()); - // ++ Before 2.1 Fork ++++++++++++++++++++++++++++++++++++++++++++++++++++++ - // => Period 1 - if ( - !poxInfo.current_burnchain_block_height || - !poxInfo.contract_versions || - poxInfo.contract_versions.length <= 1 - ) { - // Node does not know about other pox versions yet - return { period: PoxOperationPeriod.Period1, pox1: { contract_id: poxInfo.contract_id } }; - } - const poxContractVersions = [...poxInfo.contract_versions].sort( (a, b) => a.activation_burnchain_block_height - b.activation_burnchain_block_height ); // by activation height ASC (earliest first) - const [pox1, pox2, pox3] = poxContractVersions; + const [pox1, pox2, pox3, pox4] = poxContractVersions; + const activatedPoxs = poxContractVersions.filter( (c: ContractVersion) => (poxInfo?.current_burnchain_block_height as number) >= c.activation_burnchain_block_height ); - // Named pox contracts but also a more future-proof current pointer to latest activated PoX contract const current = activatedPoxs[activatedPoxs.length - 1]; - if (poxInfo.contract_versions.length == 2) { - const [address, name] = pox2.contract_id.split('.'); - const pox2ConfiguredUrl = this.network.getDataVarUrl(address, name, 'configured'); - const isPox2NotYetConfigured = - (await this.network.fetchFn(pox2ConfiguredUrl).then(r => r.text())) !== '{"data":"0x03"}'; // PoX-2 is configured on fork if data is 0x03 - - // => Period 1 - if (isPox2NotYetConfigured) { - // Node hasn't forked yet (unclear if this case can happen on a non-mocknet/regtest node) - return { period: PoxOperationPeriod.Period1, pox1, pox2 }; - } - } - - // ++ >= 2.1 Fork ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - // => Period 2a - if (poxInfo.contract_id === pox1.contract_id) { - // In 2.1 fork, but PoX-2 hasn't been activated yet - return { period: PoxOperationPeriod.Period2a, pox1, pox2, current }; - } - - // ++ PoX-2 was activated ++++++++++++++++++++++++++++++++++++++++++++++++++ - if (poxInfo.contract_id === pox2.contract_id) { - // => Period 2b - if (poxInfo.current_cycle.id < pox2.first_reward_cycle_id) { - // In 2.1 fork and PoX-2 is live - return { period: PoxOperationPeriod.Period2b, pox1, pox2, current }; - } - - // => Period 3 - return { period: PoxOperationPeriod.Period3, pox1, pox2, current }; - } - - // ++ Post PoX-2 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - if (activatedPoxs.length > 2) { - // More than two PoX contracts have been activated - // => (Still) Period 3 - return { period: PoxOperationPeriod.Period3, pox1, pox2, pox3, current }; - } - - throw new Error('Could not determine PoX Operation Period'); - } - - /** - * Check if stacking is enabled for next reward cycle - * - * @returns {Promise} that resolves to a bool if the operation succeeds - */ - async isStackingEnabledNextCycle(): Promise { - return (await this.getPoxInfo()).rejection_votes_left_required > 0; + return { period: PoxOperationPeriod.Period3, pox1, pox2, pox3, pox4, current }; } /** @@ -618,6 +585,8 @@ export class StackingClient { } /** + * readonly `can-stack-stx` + * * Check if account can lock stx * @param {CanLockStxOptions} options - a required lock STX options object * @returns {Promise} that resolves to a StackingEligibility object if the operation succeeds @@ -661,6 +630,8 @@ export class StackingClient { } /** + * `stack-stx` + * * Generate and broadcast a stacking transaction to lock STX * @param {LockStxOptions} options - a required lock STX options object * @returns {Promise} that resolves to a broadcasted txid if the operation succeeds @@ -670,20 +641,30 @@ export class StackingClient { poxAddress, cycles, burnBlockHeight, + signerKey, + signerSignature, + maxAmount, + authId, ...txOptions }: LockStxOptions & BaseTxOptions): Promise { const poxInfo = await this.getPoxInfo(); const poxOperationInfo = await this.getPoxOperationInfo(poxInfo); const contract = await this.getStackingContract(poxOperationInfo); + ensureLegacyBtcAddressForPox1({ contract, poxAddress }); + ensureSignerArgsReadiness({ contract, signerKey, signerSignature, maxAmount, authId }); const callOptions = this.getStackOptions({ + contract, amountMicroStx, cycles, poxAddress, - contract, burnBlockHeight, + signerKey, + signerSignature, + maxAmount, + authId, }); const tx = await makeContractCall({ ...callOptions, @@ -694,6 +675,8 @@ export class StackingClient { } /** + * `stack-extend` + * * Generate and broadcast a stacking transaction to extend locked STX (`pox-2.stack-extend`) * @category PoX-2 * @param {StackExtendOptions} - a required extend STX options object @@ -702,16 +685,32 @@ export class StackingClient { async stackExtend({ extendCycles, poxAddress, + signerKey, + signerSignature, + maxAmount, + authId, ...txOptions }: StackExtendOptions & BaseTxOptions): Promise { const poxInfo = await this.getPoxInfo(); const poxOperationInfo = await this.getPoxOperationInfo(poxInfo); + ensurePox2Activated(poxOperationInfo); + ensureSignerArgsReadiness({ + contract: poxInfo.contract_id, + signerKey, + signerSignature, + maxAmount, + authId, + }); const callOptions = this.getStackExtendOptions({ contract: poxInfo.contract_id, extendCycles, poxAddress, + signerKey, + signerSignature, + maxAmount, + authId, }); const tx = await makeContractCall({ ...callOptions, @@ -729,15 +728,30 @@ export class StackingClient { */ async stackIncrease({ increaseBy, + signerKey, + signerSignature, + maxAmount, + authId, ...txOptions }: StackIncreaseOptions & BaseTxOptions): Promise { const poxInfo = await this.getPoxInfo(); const poxOperationInfo = await this.getPoxOperationInfo(poxInfo); ensurePox2Activated(poxOperationInfo); + ensureSignerArgsReadiness({ + contract: poxInfo.contract_id, + signerKey, + signerSignature, + maxAmount, + authId, + }); const callOptions = this.getStackIncreaseOptions({ contract: poxInfo.contract_id, increaseBy, + signerKey, + signerSignature, + maxAmount, + authId, }); const tx = await makeContractCall({ ...callOptions, @@ -748,6 +762,8 @@ export class StackingClient { } /** + * `delegate-stx` + * * As a delegatee, generate and broadcast a transaction to create a delegation relationship * @param {DelegateStxOptions} options - a required delegate STX options object * @returns {Promise} that resolves to a broadcasted txid if the operation succeeds @@ -783,6 +799,8 @@ export class StackingClient { } /** + * `delegate-stack-stx` + * * As a delegator, generate and broadcast transactions to stack for multiple delegatees. This will lock up tokens owned by the delegatees. * @param {DelegateStackStxOptions} options - a required delegate stack STX options object * @returns {Promise} that resolves to a broadcasted txid if the operation succeeds @@ -818,6 +836,8 @@ export class StackingClient { } /** + * `delegate-stack-extend` + * * As a delegator, generate and broadcast transactions to extend stack for multiple delegatees. * @category PoX-2 * @param {DelegateStackExtendOptions} options - a required delegate stack extend STX options object @@ -884,16 +904,24 @@ export class StackingClient { async stackAggregationCommit({ poxAddress, rewardCycle, + signerKey, + signerSignature, + maxAmount, + authId, ...txOptions }: StackAggregationCommitOptions & BaseTxOptions): Promise { - // todo: deprecate this method in favor of Indexed as soon as PoX-2 is live const contract = await this.getStackingContract(); ensureLegacyBtcAddressForPox1({ contract, poxAddress }); + ensureSignerArgsReadiness({ contract, signerKey, signerSignature, maxAmount, authId }); const callOptions = this.getStackAggregationCommitOptions({ contract, poxAddress, rewardCycle, + signerKey, + signerSignature, + maxAmount, + authId, }); const tx = await makeContractCall({ ...callOptions, @@ -924,15 +952,24 @@ export class StackingClient { async stackAggregationCommitIndexed({ poxAddress, rewardCycle, + signerKey, + signerSignature, + maxAmount, + authId, ...txOptions }: StackAggregationCommitOptions & BaseTxOptions): Promise { const contract = await this.getStackingContract(); ensureLegacyBtcAddressForPox1({ contract, poxAddress }); + ensureSignerArgsReadiness({ contract, signerKey, signerSignature, maxAmount, authId }); const callOptions = this.getStackAggregationCommitOptionsIndexed({ contract, poxAddress, rewardCycle, + signerKey, + signerSignature, + maxAmount, + authId, }); const tx = await makeContractCall({ ...callOptions, @@ -1006,21 +1043,43 @@ export class StackingClient { cycles, contract, burnBlockHeight, + signerKey, + signerSignature, + maxAmount, + authId, }: { cycles: number; poxAddress: string; amountMicroStx: IntegerType; contract: string; burnBlockHeight: number; + signerKey?: string; + signerSignature?: string; + maxAmount?: IntegerType; + authId?: IntegerType; }) { const address = poxAddressToTuple(poxAddress); const [contractAddress, contractName] = this.parseContractId(contract); + + const functionArgs = [ + uintCV(amountMicroStx), + address, + uintCV(burnBlockHeight), + uintCV(cycles), + ] as ClarityValue[]; + + if (signerKey && maxAmount && typeof authId !== 'undefined') { + functionArgs.push(signerSignature ? someCV(bufferCV(hexToBytes(signerSignature))) : noneCV()); + functionArgs.push(bufferCV(hexToBytes(signerKey))); + functionArgs.push(uintCV(maxAmount)); + functionArgs.push(uintCV(authId)); + } + const callOptions: ContractCallOptions = { contractAddress, contractName, functionName: 'stack-stx', - // sum of uStx, address, burn_block_height, num_cycles - functionArgs: [uintCV(amountMicroStx), address, uintCV(burnBlockHeight), uintCV(cycles)], + functionArgs, validateWithAbi: true, network: this.network, anchorMode: AnchorMode.Any, @@ -1032,18 +1091,36 @@ export class StackingClient { extendCycles, poxAddress, contract, + signerKey, + signerSignature, + maxAmount, + authId, }: { extendCycles: number; poxAddress: string; contract: string; + signerKey?: string; + signerSignature?: string; + maxAmount?: IntegerType; + authId?: IntegerType; }) { const address = poxAddressToTuple(poxAddress); const [contractAddress, contractName] = this.parseContractId(contract); + + const functionArgs = [uintCV(extendCycles), address] as ClarityValue[]; + + if (signerKey && maxAmount && typeof authId !== 'undefined') { + functionArgs.push(signerSignature ? someCV(bufferCV(hexToBytes(signerSignature))) : noneCV()); + functionArgs.push(bufferCV(hexToBytes(signerKey))); + functionArgs.push(uintCV(maxAmount)); + functionArgs.push(uintCV(authId)); + } + const callOptions: ContractCallOptions = { contractAddress, contractName, functionName: 'stack-extend', - functionArgs: [uintCV(extendCycles), address], + functionArgs, validateWithAbi: true, network: this.network, anchorMode: AnchorMode.Any, @@ -1051,13 +1128,37 @@ export class StackingClient { return callOptions; } - getStackIncreaseOptions({ increaseBy, contract }: { increaseBy: IntegerType; contract: string }) { + getStackIncreaseOptions({ + increaseBy, + contract, + signerKey, + signerSignature, + maxAmount, + authId, + }: { + increaseBy: IntegerType; + contract: string; + signerKey?: string; + signerSignature?: string; + maxAmount?: IntegerType; + authId?: IntegerType; + }) { const [contractAddress, contractName] = this.parseContractId(contract); + + const functionArgs = [uintCV(increaseBy)] as ClarityValue[]; + + if (signerKey && maxAmount && typeof authId !== 'undefined') { + functionArgs.push(signerSignature ? someCV(bufferCV(hexToBytes(signerSignature))) : noneCV()); + functionArgs.push(bufferCV(hexToBytes(signerKey))); + functionArgs.push(uintCV(maxAmount)); + functionArgs.push(uintCV(authId)); + } + const callOptions: ContractCallOptions = { contractAddress, contractName, functionName: 'stack-increase', - functionArgs: [uintCV(increaseBy)], + functionArgs, validateWithAbi: true, network: this.network, anchorMode: AnchorMode.Any, @@ -1114,6 +1215,7 @@ export class StackingClient { }) { const address = poxAddressToTuple(poxAddress); const [contractAddress, contractName] = this.parseContractId(contract); + const callOptions: ContractCallOptions = { contractAddress, contractName, @@ -1146,6 +1248,7 @@ export class StackingClient { }) { const address = poxAddressToTuple(poxAddress); const [contractAddress, contractName] = this.parseContractId(contract); + const callOptions: ContractCallOptions = { contractAddress, contractName, @@ -1189,18 +1292,36 @@ export class StackingClient { contract, poxAddress, rewardCycle, + signerKey, + signerSignature, + maxAmount, + authId, }: { contract: string; poxAddress: string; rewardCycle: number; + signerKey?: string; + signerSignature?: string; + maxAmount?: IntegerType; + authId?: IntegerType; }) { const address = poxAddressToTuple(poxAddress); const [contractAddress, contractName] = this.parseContractId(contract); + + const functionArgs = [address, uintCV(rewardCycle)] as ClarityValue[]; + + if (signerKey && maxAmount && typeof authId !== 'undefined') { + functionArgs.push(signerSignature ? someCV(bufferCV(hexToBytes(signerSignature))) : noneCV()); + functionArgs.push(bufferCV(hexToBytes(signerKey))); + functionArgs.push(uintCV(maxAmount)); + functionArgs.push(uintCV(authId)); + } + const callOptions: ContractCallOptions = { contractAddress, contractName, functionName: 'stack-aggregation-commit', - functionArgs: [address, uintCV(rewardCycle)], + functionArgs, validateWithAbi: true, network: this.network, anchorMode: AnchorMode.Any, @@ -1237,18 +1358,36 @@ export class StackingClient { contract, poxAddress, rewardCycle, + signerKey, + signerSignature, + maxAmount, + authId, }: { contract: string; poxAddress: string; rewardCycle: number; + signerKey?: string; + signerSignature?: string; + maxAmount?: IntegerType; + authId?: IntegerType; }) { const address = poxAddressToTuple(poxAddress); const [contractAddress, contractName] = this.parseContractId(contract); + + const functionArgs = [address, uintCV(rewardCycle)] as ClarityValue[]; + + if (signerKey && maxAmount && typeof authId !== 'undefined') { + functionArgs.push(signerSignature ? someCV(bufferCV(hexToBytes(signerSignature))) : noneCV()); + functionArgs.push(bufferCV(hexToBytes(signerKey))); + functionArgs.push(uintCV(maxAmount)); + functionArgs.push(uintCV(authId)); + } + const callOptions: ContractCallOptions = { contractAddress, contractName, functionName: 'stack-aggregation-commit-indexed', - functionArgs: [address, uintCV(rewardCycle)], + functionArgs, validateWithAbi: true, network: this.network, anchorMode: AnchorMode.Any, @@ -1297,6 +1436,7 @@ export class StackingClient { const lockPeriod: UIntCV = tupleCV.data['lock-period'] as UIntCV; const version: BufferCV = poxAddress.data['version'] as BufferCV; const hashbytes: BufferCV = poxAddress.data['hashbytes'] as BufferCV; + const signerKey: BufferCV = tupleCV.data['signer-key'] as BufferCV; return { stacked: true, @@ -1308,6 +1448,7 @@ export class StackingClient { version: version.buffer, hashbytes: hashbytes.buffer, }, + signer_key: signerKey ? bytesToHex(signerKey.buffer) : undefined, }, }; } else if (responseCV.type === ClarityType.OptionalNone) { @@ -1405,6 +1546,7 @@ export class StackingClient { * @returns {Array} a contract address and name */ parseContractId(contract: string): string[] { + // todo: move this function to a standalone utility, and @ignore deprecate it here const parts = contract.split('.'); if (parts.length === 2 && validateStacksAddress(parts[0]) && parts[1].startsWith('pox')) { @@ -1413,9 +1555,43 @@ export class StackingClient { throw new Error('Stacking contract ID is malformed'); } + + /** + * Generates a `signer-sig` string for the current PoX contract. + */ + signPoxSignature({ + topic, + poxAddress, + rewardCycle, + period, + signerPrivateKey, + authId, + maxAmount, + }: { + topic: `${Pox4SignatureTopic}`; + poxAddress: string; + rewardCycle: number; + period: number; + signerPrivateKey: StacksPrivateKey; + maxAmount: IntegerType; + authId: number; + }) { + // todo: in the future add logic to determine if a later version of pox + // needs a different domain and thus use a different `signPox4SignatureHash` + return signPox4SignatureHash({ + topic, + poxAddress, + rewardCycle, + period, + network: this.network, + privateKey: signerPrivateKey, + maxAmount, + authId, + }); + } } -/** Rename `privateKey` to `senderKey`, for backwards compatibility */ +/** @ignore Rename `privateKey` to `senderKey`, for backwards compatibility */ function renamePrivateKey(txOptions: BaseTxOptions) { // @ts-ignore txOptions.senderKey = txOptions.privateKey; diff --git a/packages/stacking/src/utils.ts b/packages/stacking/src/utils.ts index 3372dd681..f49778cdb 100644 --- a/packages/stacking/src/utils.ts +++ b/packages/stacking/src/utils.ts @@ -1,14 +1,25 @@ +import { sha256 } from '@noble/hashes/sha256'; import { bech32, bech32m } from '@scure/base'; -import { bigIntToBytes } from '@stacks/common'; -import { base58CheckDecode, base58CheckEncode } from '@stacks/encryption'; +import { IntegerType, bigIntToBytes } from '@stacks/common'; +import { + base58CheckDecode, + base58CheckEncode, + verifyMessageSignatureRsv, +} from '@stacks/encryption'; +import { StacksNetwork, StacksNetworkName, StacksNetworks } from '@stacks/network'; import { - bufferCV, BufferCV, ClarityType, ClarityValue, OptionalCV, - tupleCV, + StacksPrivateKey, TupleCV, + bufferCV, + encodeStructuredData, + signStructuredData, + stringAsciiCV, + tupleCV, + uintCV, } from '@stacks/transactions'; import { PoxOperationInfo } from '.'; import { @@ -16,15 +27,14 @@ import { BitcoinNetworkVersion, PoXAddressVersion, PoxOperationPeriod, - SegwitPrefix, SEGWIT_ADDR_PREFIXES, SEGWIT_V0, SEGWIT_V0_ADDR_PREFIX, SEGWIT_V1, SEGWIT_V1_ADDR_PREFIX, + SegwitPrefix, StackingErrors, } from './constants'; -import { StacksNetworkName, StacksNetworks } from '@stacks/network'; export class InvalidAddressError extends Error { innerError?: Error; @@ -335,7 +345,7 @@ export function ensurePox2Activated(operationInfo: PoxOperationInfo) { /** * @internal - * Throws unless the given PoX address is a legacy address. + * Throws if the given PoX address is not a legacy address for PoX-1. */ export function ensureLegacyBtcAddressForPox1({ contract, @@ -349,3 +359,135 @@ export function ensureLegacyBtcAddressForPox1({ throw new Error('PoX-1 requires P2PKH/P2SH/P2SH-P2WPKH/P2SH-P2WSH bitcoin addresses'); } } + +/** + * @internal + * Throws if signer args are given for <= PoX-3 or the signer args are missing otherwise. + */ +export function ensureSignerArgsReadiness({ + contract, + signerKey, + signerSignature, + maxAmount, + authId, +}: { + contract: string; + signerKey?: string; + signerSignature?: string; + maxAmount?: IntegerType; + authId?: IntegerType; +}) { + const hasMaxAmount = typeof maxAmount !== 'undefined'; + const hasAuthId = typeof authId !== 'undefined'; + if (/\.pox(-[2-3])?$/.test(contract)) { + // .pox, .pox-2 or .pox-3 + if (signerKey || signerSignature || hasMaxAmount || hasAuthId) { + throw new Error( + 'PoX-1, PoX-2 and PoX-3 do not accept a `signerKey`, `signerSignature`, `maxAmount` or `authId`' + ); + } + } else { + // .pox-4 or later + if (!signerKey || !hasMaxAmount || typeof authId === 'undefined') { + throw new Error( + 'PoX-4 requires a `signerKey` (buff 33), `maxAmount` (uint), and `authId` (uint)' + ); + } + } +} + +export enum Pox4SignatureTopic { + StackStx = 'stack-stx', + AggregateCommit = 'agg-commit', + StackExtend = 'stack-extend', + StackIncrease = 'stack-increase', +} + +export interface Pox4SignatureOptions { + /** topic of the signature (i.e. which stacking operation the signature is used for) */ + topic: `${Pox4SignatureTopic}` | Pox4SignatureTopic; + poxAddress: string; + /** current reward cycle */ + rewardCycle: number; + /** lock period (in cycles) */ + period: number; + network: StacksNetwork; + /** Maximum amount of uSTX that can be locked during this function call */ + maxAmount: IntegerType; + /** Random integer to prevent signature re-use */ + authId: number; +} + +/** + * Generate a signature (`signer-sig` in PoX-4 stacking operations). + */ +export function signPox4SignatureHash({ + topic, + poxAddress, + rewardCycle, + period, + network, + privateKey, + maxAmount, + authId, +}: Pox4SignatureOptions & { privateKey: StacksPrivateKey }) { + return signStructuredData({ + ...pox4SignatureMessage({ topic, poxAddress, rewardCycle, period, network, maxAmount, authId }), + privateKey, + }).data; +} + +/** + * Verify a signature (`signer-sig` in PoX-4 stacking operations) matches the given + * public key (`signer-key`) and the structured data of the operation. + */ +export function verifyPox4SignatureHash({ + topic, + poxAddress, + rewardCycle, + period, + network, + publicKey, + signature, + maxAmount, + authId, +}: Pox4SignatureOptions & { publicKey: string; signature: string }) { + return verifyMessageSignatureRsv({ + message: sha256( + encodeStructuredData( + pox4SignatureMessage({ topic, poxAddress, rewardCycle, period, network, maxAmount, authId }) + ) + ), + publicKey, + signature, + }); +} + +/** + * Helper method used to generate SIP018 `message` and `domain` in + * {@link signPox4SignatureHash} and {@link verifyPox4SignatureHash}. + */ +export function pox4SignatureMessage({ + topic, + poxAddress, + rewardCycle, + period: lockPeriod, + network, + maxAmount, + authId, +}: Pox4SignatureOptions) { + const message = tupleCV({ + 'pox-addr': poxAddressToTuple(poxAddress), + 'reward-cycle': uintCV(rewardCycle), + topic: stringAsciiCV(topic), + period: uintCV(lockPeriod), + 'max-amount': uintCV(maxAmount), + 'auth-id': uintCV(authId), + }); + const domain = tupleCV({ + name: stringAsciiCV('pox-4-signer'), + version: stringAsciiCV('1.0.0'), + 'chain-id': uintCV(network.chainId), + }); + return { message, domain }; +} diff --git a/packages/stacking/tests/stacking.test.ts b/packages/stacking/tests/stacking.test.ts index d230508e0..f6c9f5e35 100644 --- a/packages/stacking/tests/stacking.test.ts +++ b/packages/stacking/tests/stacking.test.ts @@ -1,5 +1,5 @@ import { bigIntToBytes, bytesToHex, hexToBytes } from '@stacks/common'; -import { base58CheckDecode } from '@stacks/encryption'; +import { base58CheckDecode, getPublicKeyFromPrivate } from '@stacks/encryption'; import { StacksMainnet, StacksTestnet } from '@stacks/network'; import { AnchorMode, @@ -8,6 +8,8 @@ import { SignedContractCallOptions, TupleCV, bufferCV, + createStacksPrivateKey, + encodeStructuredData, intCV, noneCV, responseErrorCV, @@ -22,11 +24,18 @@ import { import fetchMock from 'jest-fetch-mock'; import { StackingClient } from '../src'; import { PoXAddressVersion, StackingErrors } from '../src/constants'; -import { decodeBtcAddress, poxAddressToBtcAddress } from '../src/utils'; +import { + decodeBtcAddress, + pox4SignatureMessage, + poxAddressToBtcAddress, + signPox4SignatureHash, + verifyPox4SignatureHash, +} from '../src/utils'; import { V2_POX_REGTEST_POX_3, setApiMocks } from './apiMockingHelpers'; +import { sha256 } from '@noble/hashes/sha256'; const poxInfo = { - contract_id: 'ST000000000000000000002AMW42H.pox', + contract_id: 'ST000000000000000000002AMW42H.pox-3', first_burnchain_block_height: 0, min_amount_ustx: 83333940625000, prepare_cycle_length: 30, @@ -35,6 +44,47 @@ const poxInfo = { reward_cycle_length: 120, rejection_votes_left_required: 12, total_liquid_supply_ustx: 40000291500000000, + pox_activation_threshold_ustx: 71566102085843, + current_burnchain_block_height: 825166, + prepare_phase_block_length: 100, + reward_phase_block_length: 2000, + reward_slots: 4000, + current_cycle: { + id: 75, + min_threshold_ustx: 90000000000, + stacked_ustx: 340958364660109, + is_pox_active: true, + }, + next_cycle: { + id: 76, + min_threshold_ustx: 90000000000, + min_increment_ustx: 71566102085, + stacked_ustx: 273136107683519, + prepare_phase_start_block_height: 825550, + blocks_until_prepare_phase: 384, + reward_phase_start_block_height: 825650, + blocks_until_reward_phase: 484, + ustx_until_pox_rejection: 357830510429200, + }, + + next_reward_cycle_in: 484, + contract_versions: [ + { + contract_id: 'ST000000000000000000002AMW42H.pox', + activation_burnchain_block_height: 666050, + first_reward_cycle_id: 0, + }, + { + contract_id: 'ST000000000000000000002AMW42H.pox-2', + activation_burnchain_block_height: 781552, + first_reward_cycle_id: 56, + }, + { + contract_id: 'ST000000000000000000002AMW42H.pox-3', + activation_burnchain_block_height: 791551, + first_reward_cycle_id: 60, + }, + ], }; const balanceInfo = { @@ -471,7 +521,6 @@ test('delegate stack stx with one delegator', async () => { } }); - // eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires const { StackingClient } = require('../src'); // needed for jest.mock module // needed for jest.mock module const client = new StackingClient(address, network); @@ -1116,3 +1165,73 @@ test('getSecondsUntilStackingDeadline', async () => { expect(seconds).toBeLessThan(0); // negative (deadline passed) expect(seconds).toBe(-50 * 10 * 60); // this time we are in the prepare phase }); + +test('correctly signs pox-4 signer signature', () => { + const network = new StacksTestnet(); + const poxAddress = 'msiYwJCvXEzjgq6hDwD9ueBka6MTfN962Z'; + + const privateKey = createStacksPrivateKey( + '002bc479cae71c410cf10113de8fe1611b148231eccdfb19ca779ba365cc511601' + ); + const publicKey = getPublicKeyFromPrivate(privateKey.data); + const maxAmount = 1000n; + const authId = 0; + + const signature = signPox4SignatureHash({ + topic: 'stack-stx', + network, + period: 12, + rewardCycle: 2, + poxAddress, + privateKey, + maxAmount, + authId, + }); + + const verified = verifyPox4SignatureHash({ + topic: 'stack-stx', + network, + period: 12, + rewardCycle: 2, + poxAddress, + signature, + publicKey, + maxAmount, + authId, + }); + + expect(verified).toBeTruthy(); // test vector also verified with pox-4.clar via clarinet + // >> (contract-call? .pox-4 verify-signer-key-sig { version: 0x00, hashbytes: 0x85d300b605fa25c983af5ceaf5b67b2b2b45c013 } u2 "stack-stx" u12 0x5689bc4403367ef91e1e2450663f2c5a31c48c815c336f73aecd705d87bd815b0f952108aa06de958fb61a6b571088de7fa4ea7df7f296c46438af3e8c3501f701 0x0329f14a91005e6b1e6f7df9d032f0f17c86a3eae25fa148e631f846486c91025f) + // (ok true) +}); + +/** + * Compare JS implementation with a fixture generated via Rust: + * + * https://github.com/stacks-network/stacks-core/blob/c9d8f8d66bea0b8983ae2a92fd9716be9dc0c4df/stackslib/src/util_lib/signed_structured_data.rs#L380 + * + */ +test('correctly generates pox-4 message hash', () => { + const poxAddress = 'mfWxJ45yp2SFn7UciZyNpvDKrzbhyfKrY8'; + const topic = 'stack-stx'; + const period = 12; + const authId = 111; + + const fixture = 'ec5b88aa81a96a6983c26cdba537a13d253425348ffc0ba6b07130869b025a2d'; + + const hash = sha256( + encodeStructuredData( + pox4SignatureMessage({ + poxAddress: poxAddress, + topic, + period, + authId, + maxAmount: 340282366920938463463374607431768211455n, + rewardCycle: 1, + network: new StacksTestnet(), + }) + ) + ); + + expect(bytesToHex(hash)).toBe(fixture); +});