diff --git a/packages/transactions/src/index.ts b/packages/transactions/src/index.ts index 3d33253f4..062efa2dd 100644 --- a/packages/transactions/src/index.ts +++ b/packages/transactions/src/index.ts @@ -25,6 +25,8 @@ export * from './utils'; export * from './address'; export * from './wire'; +export * from './namespaces'; + /** * ### `Cl.` Clarity Value Namespace * The `Cl` namespace is provided as a convenience to build/parse Clarity Value objects. diff --git a/packages/transactions/src/keys.ts b/packages/transactions/src/keys.ts index 95b9a5b8e..1ebd209bf 100644 --- a/packages/transactions/src/keys.ts +++ b/packages/transactions/src/keys.ts @@ -225,6 +225,23 @@ export function signMessageHashRsv({ return { ...messageSignature, data: signatureVrsToRsv(messageSignature.data) }; } +/** + * Convert a private key to a single-sig address. + * @returns A Stacks address string (encoded with c32check) + * @example + * ``` + * const address = privateKeyToAddress("73a2f291df5a8ce3ceb668a25ac7af45639513af7596d710ddf59f64f484fd2801"); + * // SP10J81WVGVB3M4PHQN4Q4G0R8586TBJH948RESDR + * ``` + */ +export function privateKeyToAddress( + privateKey: PrivateKey, + network?: StacksNetworkName | StacksNetwork +): string { + const publicKey = privateKeyToPublic(privateKey); + return publicKeyToAddressSingleSig(publicKey, network); +} + /** * Convert a public key to an address. * @returns A Stacks address string (encoded with c32check) diff --git a/packages/transactions/src/namespaces/address.ts b/packages/transactions/src/namespaces/address.ts new file mode 100644 index 000000000..52cd70bcb --- /dev/null +++ b/packages/transactions/src/namespaces/address.ts @@ -0,0 +1,108 @@ +import { c32address, c32addressDecode } from 'c32check'; +import { AddressVersion } from '../constants'; +import { privateKeyToAddress, publicKeyToAddressSingleSig } from '../keys'; +import { AddressString, ContractIdString } from '../types'; + +const C32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export type AddressRepr = { hash160: string; contractName?: string } & ( + | { + version: AddressVersion; + versionChar: string; + } + | { + version: AddressVersion; + } + | { + versionChar: string; + } +); + +/** + * Parse a C32 Stacks address string to an address object. + * @param address - The address string to parse. + * @example + * ```ts + * import { Address } from '@stacks/transactions'; + * + * const address = Address.parse('SP000000000000000000002Q6VF78'); + * // { version: 22, versionChar: 'P', hash160: '0000000000000000000000000000000000000000' } + * + * const address = Address.parse('ST000000000000000000002AMW42H.pox'); + * // { version: 22, versionChar: 'P', hash160: '0000000000000000000000000000000000000000', contractName: 'pox' } + * ``` + */ +export function parse( + address: + | AddressString + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | ContractIdString +): AddressRepr { + const [addr, contractName] = address.split('.'); + const parsed = c32addressDecode(addr); + return { + version: parsed[0], + versionChar: C32[parsed[0]], + hash160: parsed[1], + contractName: contractName, + }; +} + +/** + * Stringify an address to the C32 address format. + * @param address - The address object to stringify. + * @example + * ```ts + * import { Address } from '@stacks/transactions'; + * + * const address = Address.stringify({ version: 22, hash160: '0000000000000000000000000000000000000000' }); + * console.log(address); // 'SP000000000000000000002Q6VF78' + * + * const address = Address.stringify({ versionChar: 'P', hash160: '0000000000000000000000000000000000000000' }); + * console.log(address); // 'SP000000000000000000002Q6VF78' + * ``` + */ +export function stringify(address: AddressRepr): string { + const version = + 'version' in address ? address.version : C32.indexOf(address.versionChar.toUpperCase()); + const addr = c32address(version, address.hash160); + + if (address.contractName) return `${addr}.${address.contractName}`; + return addr; +} + +/** + * Convert a private key to a single-sig C32 Stacks address. + * @param privateKey - The private key to convert. + * @returns The address string. + * + * @example + * ```ts + * import { Address } from '@stacks/transactions'; + * + * const address = Address.fromPrivateKey('73a2f291df5a8ce3ceb668a25ac7af45639513af7596d710ddf59f64f484fd2801'); + * // 'SP10J81WVGVB3M4PHQN4Q4G0R8586TBJH948RESDR' + * + * const address = Address.fromPrivateKey('73a2f291df5a8ce3ceb668a25ac7af45639513af7596d710ddf59f64f484fd2801', 'testnet'); + * // 'ST10J81WVGVB3M4PHQN4Q4G0R8586TBJH94CGRESQ' + * ``` + */ +export const fromPrivateKey = privateKeyToAddress; + +/** + * Convert a public key to a single-sig C32 Stacks address. + * @param publicKey - The public key to convert. + * @returns The address string. + * + * @example + * ```ts + * import { Address } from '@stacks/transactions'; + * + * const address = Address.fromPublicKey('0316e35d38b52d4886e40065e4952a49535ce914e02294be58e252d1998f129b19'); + * // 'SP10J81WVGVB3M4PHQN4Q4G0R8586TBJH948RESDR' + * + * const address = Address.fromPublicKey('0316e35d38b52d4886e40065e4952a49535ce914e02294be58e252d1998f129b19', 'testnet'); + * // 'ST10J81WVGVB3M4PHQN4Q4G0R8586TBJH94CGRESQ' + * ``` + */ +export const fromPublicKey = publicKeyToAddressSingleSig; diff --git a/packages/transactions/src/namespaces/index.ts b/packages/transactions/src/namespaces/index.ts new file mode 100644 index 000000000..6df518c0e --- /dev/null +++ b/packages/transactions/src/namespaces/index.ts @@ -0,0 +1 @@ +export * as Address from './address'; diff --git a/packages/transactions/tests/keys.test.ts b/packages/transactions/tests/keys.test.ts index 8ffb99d5b..7317b2467 100644 --- a/packages/transactions/tests/keys.test.ts +++ b/packages/transactions/tests/keys.test.ts @@ -13,7 +13,7 @@ import { signatureRsvToVrs, utf8ToBytes, } from '@stacks/common'; -import { AddressVersion, TransactionVersion } from '@stacks/network'; +import { AddressVersion, STACKS_TESTNET, TransactionVersion } from '@stacks/network'; import { ec as EC } from 'elliptic'; import { PubKeyEncoding, @@ -24,6 +24,7 @@ import { getAddressFromPrivateKey, getAddressFromPublicKey, makeRandomPrivKey, + privateKeyToAddress, privateKeyToHex, privateKeyToPublic, publicKeyFromSignatureRsv, @@ -330,3 +331,15 @@ describe(publicKeyToAddress.name, () => { expect(address).toBe('SPAW66WC3G8WA5F28JVNG1NTRJ6H76E7EN5H6QQD'); }); }); + +describe(privateKeyToAddress.name, () => { + it('should return the correct single-sig address', () => { + const privateKey = '73a2f291df5a8ce3ceb668a25ac7af45639513af7596d710ddf59f64f484fd2801'; + + const address = privateKeyToAddress(privateKey); + expect(address).toBe('SP10J81WVGVB3M4PHQN4Q4G0R8586TBJH948RESDR'); + + const addressTestnet = privateKeyToAddress(privateKey, STACKS_TESTNET); + expect(addressTestnet).toBe('ST10J81WVGVB3M4PHQN4Q4G0R8586TBJH94CGRESQ'); + }); +}); diff --git a/packages/transactions/tests/namespaces/address.test.ts b/packages/transactions/tests/namespaces/address.test.ts new file mode 100644 index 000000000..c66126258 --- /dev/null +++ b/packages/transactions/tests/namespaces/address.test.ts @@ -0,0 +1,85 @@ +import { Address } from '../../src'; + +describe('Address Namespace', () => { + describe(Address.parse, () => { + it('should parse addresses correctly', () => { + const address = Address.parse('SP000000000000000000002Q6VF78'); + expect(address).toEqual({ version: 22, versionChar: 'P', hash160: '0'.repeat(40) }); + + const addressWithVersionChar = Address.parse('SP000000000000000000002Q6VF78'); + expect(addressWithVersionChar).toEqual({ + version: 22, + versionChar: 'P', + hash160: '0'.repeat(40), + }); + + const addressWithContractId = Address.parse('ST000000000000000000002AMW42H.pox'); + expect(addressWithContractId).toEqual({ + version: 26, + versionChar: 'T', + hash160: '0'.repeat(40), + contractName: 'pox', + }); + }); + + it('should throw an error for invalid addresses', () => { + expect(() => Address.parse('invalid')).toThrow('Invalid c32 address: must start with "S"'); + }); + + it('should throw an error for checksum mismatches', () => { + expect(() => Address.parse('ST000BLABLA')).toThrow( + 'Invalid c32check string: checksum mismatch' + ); + }); + }); + + describe(Address.stringify, () => { + it('should return the correct address', () => { + const addressWithVersion = Address.stringify({ version: 22, hash160: '0'.repeat(40) }); + expect(addressWithVersion).toBe('SP000000000000000000002Q6VF78'); + + const addressWithVersionChar = Address.stringify({ + versionChar: 'P', + hash160: '0'.repeat(40), + }); + expect(addressWithVersionChar).toBe('SP000000000000000000002Q6VF78'); + + const addressWithContractId = Address.stringify({ + versionChar: 'T', + hash160: '0'.repeat(40), + contractName: 'pox', + }); + expect(addressWithContractId).toBe('ST000000000000000000002AMW42H.pox'); + }); + }); + + describe(Address.fromPrivateKey, () => { + it('should return the correct address', () => { + const address = Address.fromPrivateKey( + '73a2f291df5a8ce3ceb668a25ac7af45639513af7596d710ddf59f64f484fd2801' + ); + expect(address).toBe('SP10J81WVGVB3M4PHQN4Q4G0R8586TBJH948RESDR'); + + const addressTestnet = Address.fromPrivateKey( + '73a2f291df5a8ce3ceb668a25ac7af45639513af7596d710ddf59f64f484fd2801', + 'testnet' + ); + expect(addressTestnet).toBe('ST10J81WVGVB3M4PHQN4Q4G0R8586TBJH94CGRESQ'); + }); + }); + + describe(Address.fromPublicKey, () => { + it('should return the correct address', () => { + const address = Address.fromPublicKey( + '0316e35d38b52d4886e40065e4952a49535ce914e02294be58e252d1998f129b19' + ); + expect(address).toBe('SP10J81WVGVB3M4PHQN4Q4G0R8586TBJH948RESDR'); + + const addressTestnet = Address.fromPublicKey( + '0316e35d38b52d4886e40065e4952a49535ce914e02294be58e252d1998f129b19', + 'testnet' + ); + expect(addressTestnet).toBe('ST10J81WVGVB3M4PHQN4Q4G0R8586TBJH94CGRESQ'); + }); + }); +}); diff --git a/packages/transactions/tests/namespaces/address.ts b/packages/transactions/tests/namespaces/address.ts new file mode 100644 index 000000000..3bc1654fd --- /dev/null +++ b/packages/transactions/tests/namespaces/address.ts @@ -0,0 +1,25 @@ +import { Address } from '../../src'; + +describe('Address', () => { + describe(Address.parse, () => {}); + + describe(Address.stringify, () => { + it('should return the correct address', () => { + const addressWithVersion = Address.stringify({ version: 22, hash160: '0'.repeat(40) }); + expect(addressWithVersion).toBe('SP000000000000000000002Q6VF78'); + + const addressWithVersionChar = Address.stringify({ + versionChar: 'P', + hash160: '0'.repeat(40), + }); + expect(addressWithVersionChar).toBe('SP000000000000000000002Q6VF78'); + + const addressWithContractId = Address.stringify({ + versionChar: 'T', + hash160: '0'.repeat(40), + contractName: 'pox', + }); + expect(addressWithContractId).toBe('ST000000000000000000002Q6VF78.pox'); + }); + }); +});