Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- `validateStakeCred`: Validates and extracts stake credentials from Cardano stake addresses, stake keys, and script addresses, returning the credential type, hex value, and db-sync stake address.

## 2.8.1 - 2024-10-10

### Changed
Expand Down
215 changes: 200 additions & 15 deletions src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ type BlockfrostNetwork =
| 'preprod'
| 'sanchonet';
// prefixes based on CIP5 https://github.com/cardano-foundation/CIPs/blob/master/CIP-0005/CIP-0005.md
const Prefixes = Object.freeze({
const PREFIXES = Object.freeze({
ADDR: 'addr',
ADDR_TEST: 'addr_test',
STAKE: 'stake',
STAKE_TEST: 'stake_test',
STAKE_KEY_HASH: 'stake_vkh',
STAKE_KEY: 'stake_vk',
PAYMENT_KEY_HASH: 'addr_vkh',
PAYMENT_KEY: 'addr_vk',
POOL: 'pool',
Expand Down Expand Up @@ -47,8 +49,8 @@ export const validateStakeAddress = (
const bech32Info = bech32.decode(input, 1000);

if (
(bech32Info.prefix === Prefixes.STAKE && network === 'mainnet') ||
(bech32Info.prefix === Prefixes.STAKE_TEST && network !== 'mainnet')
(bech32Info.prefix === PREFIXES.STAKE && network === 'mainnet') ||
(bech32Info.prefix === PREFIXES.STAKE_TEST && network !== 'mainnet')
)
return true;
else {
Expand All @@ -70,8 +72,8 @@ export const convertStakeAddress = (
// if it's in hex, we'll convert it to Bech32

return network === 'mainnet'
? bech32.encode(Prefixes.STAKE, words)
: bech32.encode(Prefixes.STAKE_TEST, words);
? bech32.encode(PREFIXES.STAKE, words)
: bech32.encode(PREFIXES.STAKE_TEST, words);
} catch {
return undefined;
}
Expand All @@ -89,7 +91,7 @@ export const validateAndConvertPool = (input: string): string | undefined => {
} else {
const bech32Info = bech32.decode(input, 1000);

return bech32Info.prefix === Prefixes.POOL ? input : undefined;
return bech32Info.prefix === PREFIXES.POOL ? input : undefined;
}
} catch {
return undefined;
Expand All @@ -107,20 +109,20 @@ export const paymentCredFromBech32Address = (
// compute paymentCred
try {
const bech32Info = bech32.decode(input, 1000);
if (bech32Info.prefix === Prefixes.PAYMENT_KEY_HASH) {
if (bech32Info.prefix === PREFIXES.PAYMENT_KEY_HASH) {
// valid payment_cred
const payload = bech32.fromWords(bech32Info.words);
const paymentCred = `\\x${Buffer.from(payload).toString('hex')}`;

return { paymentCred, prefix: bech32Info.prefix };
} else if (bech32Info.prefix === Prefixes.PAYMENT_KEY) {
} else if (bech32Info.prefix === PREFIXES.PAYMENT_KEY) {
// valid payment_cred
const payload = bech32.fromWords(bech32Info.words);
const pubKey = PublicKey.from_hex(Buffer.from(payload).toString('hex'));
const paymentKeyHash = `\\x${pubKey.hash().to_hex()}`;
pubKey.free();
return { paymentCred: paymentKeyHash, prefix: bech32Info.prefix };
} else if (bech32Info.prefix === Prefixes.SCRIPT) {
} else if (bech32Info.prefix === PREFIXES.SCRIPT) {
const payload = bech32.fromWords(bech32Info.words);
const payloadHex = Buffer.from(payload).toString('hex');
const paymentCred = `\\x${payloadHex}`;
Expand All @@ -147,8 +149,8 @@ export const paymentCredToBech32Address = (
const words = bech32.toWords(Buffer.from(input, 'hex'));

switch (prefix) {
case Prefixes.PAYMENT_KEY_HASH:
case Prefixes.SCRIPT:
case PREFIXES.PAYMENT_KEY_HASH:
case PREFIXES.SCRIPT:
// add prefix to payment cred and encode it as bech32
return bech32.encode(prefix, words);
default:
Expand All @@ -174,14 +176,14 @@ export const detectAndValidateAddressType = (
// check if it's not shelley (also check network mismatch i.e. mainnet/testnet)
const bech32Info = bech32.decode(input, 1000);
if (
(bech32Info.prefix === Prefixes.ADDR && network === 'mainnet') ||
(bech32Info.prefix === Prefixes.ADDR_TEST && network !== 'mainnet')
(bech32Info.prefix === PREFIXES.ADDR && network === 'mainnet') ||
(bech32Info.prefix === PREFIXES.ADDR_TEST && network !== 'mainnet')
) {
// valid shelley - addr1 for mainnet or addr_test1 for testnet
return 'shelley';
} else if (
bech32Info.prefix === Prefixes.PAYMENT_KEY_HASH ||
bech32Info.prefix === Prefixes.SCRIPT
bech32Info.prefix === PREFIXES.PAYMENT_KEY_HASH ||
bech32Info.prefix === PREFIXES.SCRIPT
) {
// valid shelley - payment_cred
return 'shelley';
Expand Down Expand Up @@ -239,6 +241,189 @@ export const scriptHashFromBech32Address = (
}
};

const getStakeAddressHeaderByte = (
type: 'keyHash' | 'scriptHash',
network: BlockfrostNetwork,
) => {
const headerAddrType = type === 'keyHash' ? 0b1110 : 0b1111; // header for stake key hash/script hash
const headerMainnet = 0b0001;
const headerTestnet = 0b0000;
const header =
(headerAddrType << 4) |
(network === 'mainnet' ? headerMainnet : headerTestnet); // Combine nibbles

const headerBuff = Buffer.alloc(1); // Allocate a 1-byte buffer (adjust size as needed)
headerBuff.writeUInt8(header, 0);
return headerBuff;
};

/**
* Constructs a Cardano stake address in db-sync format (bech32) from a stake credential.
*
* The function prepends the appropriate header byte (indicating key hash or script hash and network)
* to the provided stake credential, then encodes the result as a bech32 stake address.
*
* @param stakeCred - Hex-encoded stake credential (key hash or script hash)
* @param type - Type of credential: 'keyHash' or 'scriptHash'
* @param network - Cardano network ('mainnet', 'testnet', etc.)
* @returns Bech32-encoded stake address suitable for db-sync
*
* @example
* getDbSyncStakeAddress('cda3khwqv60360rp5m7akt50m6ttapacs8rqhn5w342z7r35m37', 'scriptHash', 'mainnet');
* // => 'stake1...'
*/
export const getDbSyncStakeAddress = (
stakeCred: string,
type: 'keyHash' | 'scriptHash',
network: BlockfrostNetwork,
): string => {
const headerBuff = getStakeAddressHeaderByte(type, network);
const keyWithHeader = Buffer.concat([
headerBuff,
Buffer.from(stakeCred, 'hex'),
]);

const dbSyncAddr = bech32.encode(
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
bech32.toWords(keyWithHeader),
);
return dbSyncAddr;
};

/**
* Validates and extracts the stake credential from a Cardano stake address or stake credential.
*
* Supported input formats:
* - Stake address (bech32): stake1..., stake_test1...
* - Stake key (bech32): stake_vk...
* - Stake key hash (bech32): stake_vkh...
* - Script hash (bech32): script...
*
* For stake addresses, the function checks the header byte to determine if the credential is a key hash or script hash.
* For stake keys, it derives the key hash from the public key.
* For script hashes, it extracts the hash directly.
*
* Returns an object containing:
* - stakeCred: Hex-encoded stake credential (key hash or script hash)
* - prefix: Bech32 prefix of the input
* - type: 'keyHash' or 'scriptHash'
* - dbSyncAddr: Stake address in db-sync format (bech32)
*
* Returns undefined if the input is invalid or not recognized.
*
* @param input - Bech32-encoded stake address or credential
* @param network - Cardano network ('mainnet', 'testnet', etc.)
* @returns Object with stakeCred, prefix, type, dbSyncAddr, or undefined if invalid
*/
export const validateStakeCred = (
input: string,
network: BlockfrostNetwork,
) => {
// Supported formats: stake, stake_test, stake_vkh, stake_vk, script

try {
const { prefix, words } = bech32.decode(input, 1000);

switch (prefix) {
case PREFIXES.STAKE:
case PREFIXES.STAKE_TEST: {
const payload = bech32.fromWords(words);

// 1110.... stake key hash
// 1111.... stake script hash
const firstByte = payload[0];
const addrTypeNibble = (firstByte & 0xf0) >> 4; // Get first 4 bits
let type = null;
switch (addrTypeNibble) {
case 0b1110:
// stake key hash
type = 'keyHash';
break;
case 0b1111:
// stake script hash
type = 'scriptHash';
break;
default:
return;
}

const stakeCred = Buffer.from(payload).slice(1).toString('hex');

return { prefix: prefix, type, stakeCred, dbSyncAddr: input };
}
case PREFIXES.STAKE_KEY: {
const payload = bech32.fromWords(words);
const pubKey = PublicKey.from_hex(Buffer.from(payload).toString('hex'));
const stakeCredKeyHash = pubKey.hash().to_hex();
pubKey.free();

const headerBuff = getStakeAddressHeaderByte('keyHash', network);
const keyWithHeader = Buffer.concat([
headerBuff,
Buffer.from(stakeCredKeyHash, 'hex'),
]);

const dbSyncAddr = bech32.encode(
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
bech32.toWords(keyWithHeader),
);

return {
stakeCred: stakeCredKeyHash,
prefix: prefix,
type: 'keyHash',
dbSyncAddr,
};
}
case PREFIXES.STAKE_KEY_HASH: {
const payload = bech32.fromWords(words);
const stakeCred = Buffer.from(payload).toString('hex');

const headerBuff = getStakeAddressHeaderByte('keyHash', network);
const keyWithHeader = Buffer.concat([headerBuff, Buffer.from(payload)]);

const dbSyncAddr = bech32.encode(
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
bech32.toWords(keyWithHeader),
);

return {
stakeCred: stakeCred,
prefix: prefix,
type: 'keyHash',
dbSyncAddr,
};
}
case PREFIXES.SCRIPT: {
const payload = bech32.fromWords(words);
const stakeCred = Buffer.from(payload).toString('hex');

const headerBuff = getStakeAddressHeaderByte('scriptHash', network);
const keyWithHeader = Buffer.concat([headerBuff, Buffer.from(payload)]);

const dbSyncAddr = bech32.encode(
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
bech32.toWords(keyWithHeader),
);

return {
stakeCred: stakeCred,
prefix: prefix,
type: 'scriptHash',
dbSyncAddr,
};
}
default: {
return undefined;
}
}
} catch (error) {
// Uncomment for awesome debug hack!
// console.error(error);
return;
}
};

export const validatePositiveInRangeSignedInt = (
possiblePositiveInt: string | number | undefined,
): boolean => {
Expand Down
85 changes: 85 additions & 0 deletions test/fixtures/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,91 @@ export const validateStakeAddress = [
},
] as const;

export const validateStakeCred = [
{
description: 'Valid stake address type-14 (key hash)',
input: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
network: 'mainnet',
result: {
prefix: 'stake',
type: 'keyHash',
stakeCred: '337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251',
dbSyncAddr: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
},
},
{
// hash_raw \xe10107002fe533cfabc484a0e1725234b0a2d9962ec8ffb2e0c6c1f4b1
description: 'Valid stake address type-14 (key hash)',
input: 'stake1uyqswqp0u5eul27ysjswzujjxjc29kvk9my0lvhqcmqlfvg7z6zve',
network: 'mainnet',
result: {
prefix: 'stake',
type: 'keyHash',
stakeCred: '0107002fe533cfabc484a0e1725234b0a2d9962ec8ffb2e0c6c1f4b1',
dbSyncAddr: 'stake1uyqswqp0u5eul27ysjswzujjxjc29kvk9my0lvhqcmqlfvg7z6zve',
},
},
{
description: 'valid stake_vk cred (cip 19 test vector)',
input:
'stake_vk1px4j0r2fk7ux5p23shz8f3y5y2qam7s954rgf3lg5merqcj6aetsft99wu',
network: 'mainnet',
result: {
prefix: 'stake_vk',
type: 'keyHash',
stakeCred: '337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251',
dbSyncAddr: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
},
},
{
description: 'Valid testnet stake address type-14 (key hash)',
input: 'stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn',
network: 'preview',
result: {
prefix: 'stake_test',
type: 'keyHash',
stakeCred: '337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251',
dbSyncAddr:
'stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn',
},
},
{
description: 'Valid stake address type-15 (script)',
input: 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5',
network: 'mainnet',
result: {
prefix: 'stake',
type: 'scriptHash',
stakeCred: 'c37b1b5dc0669f1d3c61a6fddb2e8fde96be87b881c60bce8e8d542f',
dbSyncAddr: 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5',
},
},
{
description: 'valid script addr (cip 19 test vector)',
input: 'script1cda3khwqv60360rp5m7akt50m6ttapacs8rqhn5w342z7r35m37',
network: 'mainnet',
result: {
prefix: 'script',
type: 'scriptHash',
stakeCred: 'c37b1b5dc0669f1d3c61a6fddb2e8fde96be87b881c60bce8e8d542f',
dbSyncAddr: 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5',
},
},
{
// addr_test1qzamu40sglnsrylzv9jylekjmzgaqsg5v5z9u6yk3jpnnxjwck77fqu8deuumsvnazjnjhwasc2eetfqpa2pvygts78ssd5388
description: 'valid stake_vkh',
input: 'stake_vkh1fmzmmeyrsah8nnwpj0522w2amkrpt89dyq84g9s3pwrc7dqjnfu',
network: 'preview',
result: {
prefix: 'stake_vkh',
type: 'keyHash',
stakeCred: '4ec5bde483876e79cdc193e8a5395ddd86159cad200f5416110b878f',
dbSyncAddr:
'stake_test1up8vt00yswrku7wdcxf73ffethwcv9vu45sq74qkzy9c0rc5lytmp',
},
},
] as const;

export const convertStakeAddress = [
{
description: 'Valid onchain address',
Expand Down
Loading