@stacks/stacking
Version:
Library for Stacking.
512 lines (480 loc) • 16.9 kB
text/typescript
import { sha256 } from '@noble/hashes/sha256';
import { bech32, bech32m } from '@scure/base';
import { IntegerType, PrivateKey, bigIntToBytes, bytesToHex, hexToBytes } from '@stacks/common';
import {
base58CheckDecode,
base58CheckEncode,
verifyMessageSignatureRsv,
} from '@stacks/encryption';
import { StacksNetwork, StacksNetworkName, StacksNetworks, networkFrom } from '@stacks/network';
import {
BufferCV,
ClarityType,
ClarityValue,
OptionalCV,
TupleCV,
bufferCV,
encodeStructuredDataBytes,
signStructuredData,
stringAsciiCV,
tupleCV,
uintCV,
} from '@stacks/transactions';
import { PoxOperationInfo } from '.';
import {
B58_ADDR_PREFIXES,
BitcoinNetworkVersion,
PoXAddressVersion,
PoxOperationPeriod,
SEGWIT_ADDR_PREFIXES,
SEGWIT_V0,
SEGWIT_V0_ADDR_PREFIX,
SEGWIT_V1,
SEGWIT_V1_ADDR_PREFIX,
SegwitPrefix,
StackingErrors,
} from './constants';
export class InvalidAddressError extends Error {
innerError?: Error;
constructor(address: string, innerError?: Error) {
const msg = `'${address}' is not a valid P2PKH/P2SH/P2WPKH/P2WSH/P2TR address`;
super(msg);
this.message = msg;
this.name = this.constructor.name;
this.innerError = innerError;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
/** @ignore */
export function btcAddressVersionToLegacyHashMode(btcAddressVersion: number): PoXAddressVersion {
switch (btcAddressVersion) {
case BitcoinNetworkVersion.mainnet.P2PKH:
return PoXAddressVersion.P2PKH;
case BitcoinNetworkVersion.testnet.P2PKH:
return PoXAddressVersion.P2PKH;
case BitcoinNetworkVersion.mainnet.P2SH:
return PoXAddressVersion.P2SH;
case BitcoinNetworkVersion.testnet.P2SH:
return PoXAddressVersion.P2SH;
default:
throw new Error('Invalid pox address version');
}
}
/** @ignore */
function nativeAddressToSegwitVersion(
witnessVersion: number,
dataLength: number
): PoXAddressVersion {
if (witnessVersion === SEGWIT_V0 && dataLength === 20) return PoXAddressVersion.P2WPKH;
if (witnessVersion === SEGWIT_V0 && dataLength === 32) return PoXAddressVersion.P2WSH;
if (witnessVersion === SEGWIT_V1 && dataLength === 32) return PoXAddressVersion.P2TR;
throw new Error(
'Invalid native segwit witness version and byte length. Currently, only P2WPKH, P2WSH, and P2TR are supported.'
);
}
/** @ignore */
function bech32Decode(btcAddress: string) {
const { words: bech32Words } = bech32.decode(btcAddress);
const witnessVersion = bech32Words[0];
if (witnessVersion > 0)
throw new Error('Addresses with a witness version >= 1 should be encoded in bech32m');
return {
witnessVersion,
data: bech32.fromWords(bech32Words.slice(1)),
};
}
/** @ignore */
function bech32MDecode(btcAddress: string) {
const { words: bech32MWords } = bech32m.decode(btcAddress);
const witnessVersion = bech32MWords[0];
if (witnessVersion == 0)
throw new Error('Addresses with witness version 1 should be encoded in bech32');
return {
witnessVersion,
data: bech32m.fromWords(bech32MWords.slice(1)),
};
}
/** @ignore */
function decodeNativeSegwitBtcAddress(btcAddress: string): {
witnessVersion: number;
data: Uint8Array;
} {
if (SEGWIT_V0_ADDR_PREFIX.test(btcAddress)) return bech32Decode(btcAddress);
if (SEGWIT_V1_ADDR_PREFIX.test(btcAddress)) return bech32MDecode(btcAddress);
throw new Error(
`Native segwit address ${btcAddress} does not match valid prefix ${SEGWIT_V0_ADDR_PREFIX} or ${SEGWIT_V1_ADDR_PREFIX}`
);
}
export function decodeBtcAddress(btcAddress: string): {
version: PoXAddressVersion;
data: string;
} {
const { version, data } = decodeBtcAddressBytes(btcAddress);
return { version, data: bytesToHex(data) };
}
export function decodeBtcAddressBytes(btcAddress: string): {
version: PoXAddressVersion;
data: Uint8Array;
} {
try {
if (B58_ADDR_PREFIXES.test(btcAddress)) {
const b58 = base58CheckDecode(btcAddress);
const addressVersion = btcAddressVersionToLegacyHashMode(b58.version);
return {
version: addressVersion,
data: b58.hash,
};
} else if (SEGWIT_ADDR_PREFIXES.test(btcAddress)) {
const b32 = decodeNativeSegwitBtcAddress(btcAddress);
const addressVersion = nativeAddressToSegwitVersion(b32.witnessVersion, b32.data.length);
return {
version: addressVersion,
data: b32.data,
};
}
throw new Error('Unknown BTC address prefix.');
} catch (error) {
throw new InvalidAddressError(btcAddress, error as Error);
}
}
export function extractPoxAddressFromClarityValue(poxAddrClarityValue: ClarityValue): {
version: number;
hashBytes: Uint8Array;
} {
const clarityValue = poxAddrClarityValue as TupleCV;
if (clarityValue.type !== ClarityType.Tuple || !clarityValue.value) {
throw new Error('Invalid argument, expected ClarityValue to be a TupleCV');
}
if (!('version' in clarityValue.value) || !('hashbytes' in clarityValue.value)) {
throw new Error(
'Invalid argument, expected Clarity tuple value to contain `version` and `hashbytes` keys'
);
}
const versionCV = clarityValue.value['version'] as BufferCV;
const hashBytesCV = clarityValue.value['hashbytes'] as BufferCV;
if (versionCV.type !== ClarityType.Buffer || hashBytesCV.type !== ClarityType.Buffer) {
throw new Error(
'Invalid argument, expected Clarity tuple value to contain `version` and `hashbytes` buffers'
);
}
return {
version: hexToBytes(versionCV.value)[0],
hashBytes: hexToBytes(hashBytesCV.value),
};
}
export function getErrorString(error: StackingErrors): string {
switch (error) {
case StackingErrors.ERR_STACKING_UNREACHABLE:
return 'Stacking unreachable';
case StackingErrors.ERR_STACKING_CORRUPTED_STATE:
return 'Stacking state is corrupted';
case StackingErrors.ERR_STACKING_INSUFFICIENT_FUNDS:
return 'Insufficient funds';
case StackingErrors.ERR_STACKING_INVALID_LOCK_PERIOD:
return 'Invalid lock period';
case StackingErrors.ERR_STACKING_ALREADY_STACKED:
return 'Account already stacked. Concurrent stacking not allowed.';
case StackingErrors.ERR_STACKING_NO_SUCH_PRINCIPAL:
return 'Principal does not exist';
case StackingErrors.ERR_STACKING_EXPIRED:
return 'Stacking expired';
case StackingErrors.ERR_STACKING_STX_LOCKED:
return 'STX balance is locked';
case StackingErrors.ERR_STACKING_PERMISSION_DENIED:
return 'Permission denied';
case StackingErrors.ERR_STACKING_THRESHOLD_NOT_MET:
return 'Stacking threshold not met';
case StackingErrors.ERR_STACKING_POX_ADDRESS_IN_USE:
return 'PoX address already in use';
case StackingErrors.ERR_STACKING_INVALID_POX_ADDRESS:
return 'Invalid PoX address';
case StackingErrors.ERR_STACKING_ALREADY_REJECTED:
return 'Stacking already rejected';
case StackingErrors.ERR_STACKING_INVALID_AMOUNT:
return 'Invalid amount';
case StackingErrors.ERR_NOT_ALLOWED:
return 'Stacking not allowed';
case StackingErrors.ERR_STACKING_ALREADY_DELEGATED:
return 'Already delegated';
case StackingErrors.ERR_DELEGATION_EXPIRES_DURING_LOCK:
return 'Delegation expires during lock period';
case StackingErrors.ERR_DELEGATION_TOO_MUCH_LOCKED:
return 'Delegation too much locked';
case StackingErrors.ERR_DELEGATION_POX_ADDR_REQUIRED:
return 'PoX address required for delegation';
case StackingErrors.ERR_INVALID_START_BURN_HEIGHT:
return 'Invalid start burn height';
case StackingErrors.ERR_NOT_CURRENT_STACKER: // not used in pox contract
return 'ERR_NOT_CURRENT_STACKER';
case StackingErrors.ERR_STACK_EXTEND_NOT_LOCKED:
return 'Stacker must be currently locked';
case StackingErrors.ERR_STACK_INCREASE_NOT_LOCKED:
return 'Stacker must be currently locked';
case StackingErrors.ERR_DELEGATION_NO_REWARD_SLOT:
return 'Invalid reward-cycle and reward-cycle-index';
case StackingErrors.ERR_DELEGATION_WRONG_REWARD_SLOT:
return 'PoX address must match the one on record';
case StackingErrors.ERR_STACKING_IS_DELEGATED:
return 'Stacker must be directly stacking and not delegating';
case StackingErrors.ERR_STACKING_NOT_DELEGATED:
return 'Stacker must be delegating and not be directly stacking';
}
}
/**
* Converts a PoX address to a tuple (e.g. to be used in a Clarity contract call).
*
* @param poxAddress - The PoX bitcoin address to be converted.
* @returns The converted PoX address as a tuple of version and hashbytes.
*/
export function poxAddressToTuple(poxAddress: string) {
const { version, data } = decodeBtcAddressBytes(poxAddress);
const versionBuff = bufferCV(bigIntToBytes(BigInt(version), 1));
const hashBuff = bufferCV(data);
return tupleCV({
version: versionBuff,
hashbytes: hashBuff,
});
}
function legacyHashModeToBtcAddressVersion(
hashMode: PoXAddressVersion,
network: StacksNetworkName
): number {
switch (hashMode) {
case PoXAddressVersion.P2PKH:
return BitcoinNetworkVersion[network].P2PKH;
case PoXAddressVersion.P2SH:
case PoXAddressVersion.P2SHP2WPKH:
case PoXAddressVersion.P2SHP2WSH:
// P2SHP2WPKH and P2SHP2WSH are treated as P2SH for the sender
return BitcoinNetworkVersion[network].P2SH;
default:
throw new Error('Invalid pox address version');
}
}
function _poxAddressToBtcAddress_Values(
version: number,
hash: string | Uint8Array,
network: StacksNetworkName
): string {
if (!StacksNetworks.includes(network)) throw new Error('Invalid network.');
if (typeof hash === 'string') hash = hexToBytes(hash);
switch (version) {
case PoXAddressVersion.P2PKH:
case PoXAddressVersion.P2SH:
case PoXAddressVersion.P2SHP2WPKH:
case PoXAddressVersion.P2SHP2WSH: {
const btcAddrVersion = legacyHashModeToBtcAddressVersion(version, network);
return base58CheckEncode(btcAddrVersion, hash);
}
case PoXAddressVersion.P2WPKH:
case PoXAddressVersion.P2WSH: {
const words = bech32.toWords(hash);
return bech32.encode(SegwitPrefix[network], [SEGWIT_V0, ...words]);
}
case PoXAddressVersion.P2TR: {
const words = bech32m.toWords(hash);
return bech32m.encode(SegwitPrefix[network], [SEGWIT_V1, ...words]);
}
}
throw new Error(`Unexpected address version: ${version}`);
}
function _poxAddressToBtcAddress_ClarityValue(
poxAddrClarityValue: ClarityValue,
network: StacksNetworkName
): string {
const poxAddr = extractPoxAddressFromClarityValue(poxAddrClarityValue);
return _poxAddressToBtcAddress_Values(poxAddr.version, poxAddr.hashBytes, network);
}
/**
* Converts a PoX address to a Bitcoin address.
*
* @param version - The version of the PoX address (as a single number, not a Uint8array).
* @param hash - The hash bytes of the PoX address.
* @param network - The network the PoX address is on.
* @returns The corresponding Bitcoin address.
*/
export function poxAddressToBtcAddress(
version: number,
hash: string | Uint8Array,
network: StacksNetworkName // todo: allow NetworkParam in the future (minor)
): string;
/**
* Converts a PoX address to a Bitcoin address.
*
* @param poxAddrClarityValue - The clarity tuple of the PoX address (version and hashbytes).
* @param network - The network the PoX address is on.
* @returns The corresponding Bitcoin address.
*/
export function poxAddressToBtcAddress(
poxAddrClarityValue: ClarityValue,
network: StacksNetworkName
): string;
export function poxAddressToBtcAddress(...args: any[]): string {
// todo: allow these helpers to take a bitcoin network instead of a stacks network, once we have a concept of bitcoin networks in the codebase
if (typeof args[0] === 'number') return _poxAddressToBtcAddress_Values(args[0], args[1], args[2]);
return _poxAddressToBtcAddress_ClarityValue(args[0], args[1]);
}
// todo: move unwrap to tx package and document
export function unwrap<T extends ClarityValue>(optional: OptionalCV<T>) {
if (optional.type === ClarityType.OptionalSome) return optional.value;
if (optional.type === ClarityType.OptionalNone) return undefined;
throw new Error("Object is not an 'Optional'");
}
export function unwrapMap<T extends ClarityValue, U>(optional: OptionalCV<T>, map: (t: T) => U) {
if (optional.type === ClarityType.OptionalSome) return map(optional.value);
if (optional.type === ClarityType.OptionalNone) return undefined;
throw new Error("Object is not an 'Optional'");
}
/** @internal */
export function ensurePox2Activated(operationInfo: PoxOperationInfo) {
if (operationInfo.period === PoxOperationPeriod.Period1)
throw new Error(
`PoX-2 has not activated yet (currently in period ${operationInfo.period} of PoX-2 operation)`
);
}
/**
* @internal
* Throws if the given PoX address is not a legacy address for PoX-1.
*/
export function ensureLegacyBtcAddressForPox1({
contract,
poxAddress,
}: {
contract: string;
poxAddress?: string;
}) {
if (!poxAddress) return;
if (contract.endsWith('.pox') && !B58_ADDR_PREFIXES.test(poxAddress)) {
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',
AggregateIncrease = 'agg-increase',
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: StacksNetworkName | StacksNetwork;
/** Maximum amount of uSTX that can be locked during this function call */
maxAmount: IntegerType;
/** Random integer to prevent signature re-use */
authId: IntegerType;
}
/**
* Generate a signature (`signer-sig` in PoX-4 stacking operations).
*/
export function signPox4SignatureHash({
topic,
poxAddress,
rewardCycle,
period,
network,
privateKey,
maxAmount,
authId,
}: Pox4SignatureOptions & { privateKey: PrivateKey }) {
return signStructuredData({
...pox4SignatureMessage({ topic, poxAddress, rewardCycle, period, network, maxAmount, authId }),
privateKey,
});
}
/**
* 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(
encodeStructuredDataBytes(
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: networkOrName,
maxAmount,
authId,
}: Pox4SignatureOptions) {
const network = networkFrom(networkOrName);
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 };
}