Skip to content

Commit 011a2ef

Browse files
committed
feat: validateStakeCred
1 parent cccaf99 commit 011a2ef

4 files changed

Lines changed: 299 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- `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.
13+
1014
## 2.8.1 - 2024-10-10
1115

1216
### Changed

src/validation.ts

Lines changed: 201 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ type BlockfrostNetwork =
1111
| 'preprod'
1212
| 'sanchonet';
1313
// prefixes based on CIP5 https://github.com/cardano-foundation/CIPs/blob/master/CIP-0005/CIP-0005.md
14-
const Prefixes = Object.freeze({
14+
const PREFIXES = Object.freeze({
1515
ADDR: 'addr',
1616
ADDR_TEST: 'addr_test',
1717
STAKE: 'stake',
1818
STAKE_TEST: 'stake_test',
19+
STAKE_KEY_HASH: 'stake_vkh',
20+
STAKE_KEY: 'stake_vk',
1921
PAYMENT_KEY_HASH: 'addr_vkh',
2022
PAYMENT_KEY: 'addr_vk',
2123
POOL: 'pool',
@@ -47,8 +49,8 @@ export const validateStakeAddress = (
4749
const bech32Info = bech32.decode(input, 1000);
4850

4951
if (
50-
(bech32Info.prefix === Prefixes.STAKE && network === 'mainnet') ||
51-
(bech32Info.prefix === Prefixes.STAKE_TEST && network !== 'mainnet')
52+
(bech32Info.prefix === PREFIXES.STAKE && network === 'mainnet') ||
53+
(bech32Info.prefix === PREFIXES.STAKE_TEST && network !== 'mainnet')
5254
)
5355
return true;
5456
else {
@@ -70,8 +72,8 @@ export const convertStakeAddress = (
7072
// if it's in hex, we'll convert it to Bech32
7173

7274
return network === 'mainnet'
73-
? bech32.encode(Prefixes.STAKE, words)
74-
: bech32.encode(Prefixes.STAKE_TEST, words);
75+
? bech32.encode(PREFIXES.STAKE, words)
76+
: bech32.encode(PREFIXES.STAKE_TEST, words);
7577
} catch {
7678
return undefined;
7779
}
@@ -89,7 +91,7 @@ export const validateAndConvertPool = (input: string): string | undefined => {
8991
} else {
9092
const bech32Info = bech32.decode(input, 1000);
9193

92-
return bech32Info.prefix === Prefixes.POOL ? input : undefined;
94+
return bech32Info.prefix === PREFIXES.POOL ? input : undefined;
9395
}
9496
} catch {
9597
return undefined;
@@ -107,20 +109,20 @@ export const paymentCredFromBech32Address = (
107109
// compute paymentCred
108110
try {
109111
const bech32Info = bech32.decode(input, 1000);
110-
if (bech32Info.prefix === Prefixes.PAYMENT_KEY_HASH) {
112+
if (bech32Info.prefix === PREFIXES.PAYMENT_KEY_HASH) {
111113
// valid payment_cred
112114
const payload = bech32.fromWords(bech32Info.words);
113115
const paymentCred = `\\x${Buffer.from(payload).toString('hex')}`;
114116

115117
return { paymentCred, prefix: bech32Info.prefix };
116-
} else if (bech32Info.prefix === Prefixes.PAYMENT_KEY) {
118+
} else if (bech32Info.prefix === PREFIXES.PAYMENT_KEY) {
117119
// valid payment_cred
118120
const payload = bech32.fromWords(bech32Info.words);
119121
const pubKey = PublicKey.from_hex(Buffer.from(payload).toString('hex'));
120122
const paymentKeyHash = `\\x${pubKey.hash().to_hex()}`;
121123
pubKey.free();
122124
return { paymentCred: paymentKeyHash, prefix: bech32Info.prefix };
123-
} else if (bech32Info.prefix === Prefixes.SCRIPT) {
125+
} else if (bech32Info.prefix === PREFIXES.SCRIPT) {
124126
const payload = bech32.fromWords(bech32Info.words);
125127
const payloadHex = Buffer.from(payload).toString('hex');
126128
const paymentCred = `\\x${payloadHex}`;
@@ -147,8 +149,8 @@ export const paymentCredToBech32Address = (
147149
const words = bech32.toWords(Buffer.from(input, 'hex'));
148150

149151
switch (prefix) {
150-
case Prefixes.PAYMENT_KEY_HASH:
151-
case Prefixes.SCRIPT:
152+
case PREFIXES.PAYMENT_KEY_HASH:
153+
case PREFIXES.SCRIPT:
152154
// add prefix to payment cred and encode it as bech32
153155
return bech32.encode(prefix, words);
154156
default:
@@ -174,14 +176,14 @@ export const detectAndValidateAddressType = (
174176
// check if it's not shelley (also check network mismatch i.e. mainnet/testnet)
175177
const bech32Info = bech32.decode(input, 1000);
176178
if (
177-
(bech32Info.prefix === Prefixes.ADDR && network === 'mainnet') ||
178-
(bech32Info.prefix === Prefixes.ADDR_TEST && network !== 'mainnet')
179+
(bech32Info.prefix === PREFIXES.ADDR && network === 'mainnet') ||
180+
(bech32Info.prefix === PREFIXES.ADDR_TEST && network !== 'mainnet')
179181
) {
180182
// valid shelley - addr1 for mainnet or addr_test1 for testnet
181183
return 'shelley';
182184
} else if (
183-
bech32Info.prefix === Prefixes.PAYMENT_KEY_HASH ||
184-
bech32Info.prefix === Prefixes.SCRIPT
185+
bech32Info.prefix === PREFIXES.PAYMENT_KEY_HASH ||
186+
bech32Info.prefix === PREFIXES.SCRIPT
185187
) {
186188
// valid shelley - payment_cred
187189
return 'shelley';
@@ -239,6 +241,190 @@ export const scriptHashFromBech32Address = (
239241
}
240242
};
241243

244+
const getStakeAddressHeaderByte = (
245+
type: 'keyHash' | 'scriptHash',
246+
network: BlockfrostNetwork,
247+
) => {
248+
const headerAddrType = type === 'keyHash' ? 0b1110 : 0b1111; // header for stake key hash/script hash
249+
const headerMainnet = 0b0001;
250+
const headerTestnet = 0b0000;
251+
const header =
252+
(headerAddrType << 4) |
253+
(network === 'mainnet' ? headerMainnet : headerTestnet); // Combine nibbles
254+
255+
const headerBuff = Buffer.alloc(1); // Allocate a 1-byte buffer (adjust size as needed)
256+
headerBuff.writeUInt8(header, 0);
257+
return headerBuff;
258+
};
259+
260+
/**
261+
* Constructs a Cardano stake address in db-sync format (bech32) from a stake credential.
262+
*
263+
* The function prepends the appropriate header byte (indicating key hash or script hash and network)
264+
* to the provided stake credential, then encodes the result as a bech32 stake address.
265+
*
266+
* @param stakeCred - Hex-encoded stake credential (key hash or script hash)
267+
* @param type - Type of credential: 'keyHash' or 'scriptHash'
268+
* @param network - Cardano network ('mainnet', 'testnet', etc.)
269+
* @returns Bech32-encoded stake address suitable for db-sync
270+
*
271+
* @example
272+
* getDbSyncStakeAddress('cda3khwqv60360rp5m7akt50m6ttapacs8rqhn5w342z7r35m37', 'scriptHash', 'mainnet');
273+
* // => 'stake1...'
274+
*/
275+
export const getDbSyncStakeAddress = (
276+
stakeCred: string,
277+
type: 'keyHash' | 'scriptHash',
278+
network: BlockfrostNetwork,
279+
): string => {
280+
const headerBuff = getStakeAddressHeaderByte(type, network);
281+
const keyWithHeader = Buffer.concat([
282+
headerBuff,
283+
Buffer.from(stakeCred, 'hex'),
284+
]);
285+
286+
const dbSyncAddr = bech32.encode(
287+
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
288+
bech32.toWords(keyWithHeader),
289+
);
290+
return dbSyncAddr;
291+
};
292+
293+
/**
294+
* Validates and extracts the stake credential from a Cardano stake address or stake credential.
295+
*
296+
* Supported input formats:
297+
* - Stake address (bech32): stake1..., stake_test1...
298+
* - Stake key (bech32): stake_vk...
299+
* - Stake key hash (bech32): stake_vkh...
300+
* - Script hash (bech32): script...
301+
*
302+
* For stake addresses, the function checks the header byte to determine if the credential is a key hash or script hash.
303+
* For stake keys, it derives the key hash from the public key.
304+
* For script hashes, it extracts the hash directly.
305+
*
306+
* Returns an object containing:
307+
* - stakeCred: Hex-encoded stake credential (key hash or script hash)
308+
* - prefix: Bech32 prefix of the input
309+
* - type: 'keyHash' or 'scriptHash'
310+
* - dbSyncAddr: Stake address in db-sync format (bech32)
311+
*
312+
* Returns undefined if the input is invalid or not recognized.
313+
*
314+
* @param input - Bech32-encoded stake address or credential
315+
* @param network - Cardano network ('mainnet', 'testnet', etc.)
316+
* @returns Object with stakeCred, prefix, type, dbSyncAddr, or undefined if invalid
317+
*/
318+
export const validateStakeCred = (
319+
input: string,
320+
network: BlockfrostNetwork,
321+
) => {
322+
// Supported formats: stake, stake_test, stake_vkh, stake_vk, script
323+
324+
try {
325+
const { prefix, words } = bech32.decode(input, 1000);
326+
327+
switch (prefix) {
328+
case PREFIXES.STAKE:
329+
case PREFIXES.STAKE_TEST: {
330+
// valid payment_cred
331+
const payload = bech32.fromWords(words);
332+
333+
// 1110.... stake key hash
334+
// 1111.... stake script hash
335+
const firstByte = payload[0];
336+
const addrTypeNibble = (firstByte & 0xf0) >> 4; // Get first 4 bits
337+
let type = null;
338+
switch (addrTypeNibble) {
339+
case 0b1110:
340+
// stake key hash
341+
type = 'keyHash';
342+
break;
343+
case 0b1111:
344+
// stake script hash
345+
type = 'scriptHash';
346+
break;
347+
default:
348+
return;
349+
}
350+
351+
const stakeCred = Buffer.from(payload).slice(1).toString('hex');
352+
353+
return { prefix: prefix, type, stakeCred, dbSyncAddr: input };
354+
}
355+
case PREFIXES.STAKE_KEY: {
356+
const payload = bech32.fromWords(words);
357+
const pubKey = PublicKey.from_hex(Buffer.from(payload).toString('hex'));
358+
const stakeCredKeyHash = pubKey.hash().to_hex();
359+
pubKey.free();
360+
361+
const headerBuff = getStakeAddressHeaderByte('keyHash', network);
362+
const keyWithHeader = Buffer.concat([
363+
headerBuff,
364+
Buffer.from(stakeCredKeyHash, 'hex'),
365+
]);
366+
367+
const dbSyncAddr = bech32.encode(
368+
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
369+
bech32.toWords(keyWithHeader),
370+
);
371+
372+
return {
373+
stakeCred: stakeCredKeyHash,
374+
prefix: prefix,
375+
type: 'keyHash',
376+
dbSyncAddr,
377+
};
378+
}
379+
case PREFIXES.STAKE_KEY_HASH: {
380+
const payload = bech32.fromWords(words);
381+
const stakeCred = Buffer.from(payload).toString('hex');
382+
383+
const headerBuff = getStakeAddressHeaderByte('keyHash', network);
384+
const keyWithHeader = Buffer.concat([headerBuff, Buffer.from(payload)]);
385+
386+
const dbSyncAddr = bech32.encode(
387+
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
388+
bech32.toWords(keyWithHeader),
389+
);
390+
391+
return {
392+
stakeCred: stakeCred,
393+
prefix: prefix,
394+
type: 'keyHash',
395+
dbSyncAddr,
396+
};
397+
}
398+
case PREFIXES.SCRIPT: {
399+
const payload = bech32.fromWords(words);
400+
const stakeCred = Buffer.from(payload).toString('hex');
401+
402+
const headerBuff = getStakeAddressHeaderByte('scriptHash', network);
403+
const keyWithHeader = Buffer.concat([headerBuff, Buffer.from(payload)]);
404+
405+
const dbSyncAddr = bech32.encode(
406+
network === 'mainnet' ? PREFIXES.STAKE : PREFIXES.STAKE_TEST,
407+
bech32.toWords(keyWithHeader),
408+
);
409+
410+
return {
411+
stakeCred: stakeCred,
412+
prefix: prefix,
413+
type: 'scriptHash',
414+
dbSyncAddr,
415+
};
416+
}
417+
default: {
418+
return undefined;
419+
}
420+
}
421+
} catch (error) {
422+
// Uncomment for awesome debug hack!
423+
console.error(error);
424+
return;
425+
}
426+
};
427+
242428
export const validatePositiveInRangeSignedInt = (
243429
possiblePositiveInt: string | number | undefined,
244430
): boolean => {

test/fixtures/validation.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,91 @@ export const validateStakeAddress = [
210210
},
211211
] as const;
212212

213+
export const validateStakeCred = [
214+
{
215+
description: 'Valid stake address type-14 (key hash)',
216+
input: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
217+
network: 'mainnet',
218+
result: {
219+
prefix: 'stake',
220+
type: 'keyHash',
221+
stakeCred: '337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251',
222+
dbSyncAddr: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
223+
},
224+
},
225+
{
226+
// hash_raw \xe10107002fe533cfabc484a0e1725234b0a2d9962ec8ffb2e0c6c1f4b1
227+
description: 'Valid stake address type-14 (key hash)',
228+
input: 'stake1uyqswqp0u5eul27ysjswzujjxjc29kvk9my0lvhqcmqlfvg7z6zve',
229+
network: 'mainnet',
230+
result: {
231+
prefix: 'stake',
232+
type: 'keyHash',
233+
stakeCred: '0107002fe533cfabc484a0e1725234b0a2d9962ec8ffb2e0c6c1f4b1',
234+
dbSyncAddr: 'stake1uyqswqp0u5eul27ysjswzujjxjc29kvk9my0lvhqcmqlfvg7z6zve',
235+
},
236+
},
237+
{
238+
description: 'valid stake_vk cred (cip 19 test vector)',
239+
input:
240+
'stake_vk1px4j0r2fk7ux5p23shz8f3y5y2qam7s954rgf3lg5merqcj6aetsft99wu',
241+
network: 'mainnet',
242+
result: {
243+
prefix: 'stake_vk',
244+
type: 'keyHash',
245+
stakeCred: '337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251',
246+
dbSyncAddr: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
247+
},
248+
},
249+
{
250+
description: 'Valid testnet stake address type-14 (key hash)',
251+
input: 'stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn',
252+
network: 'preview',
253+
result: {
254+
prefix: 'stake_test',
255+
type: 'keyHash',
256+
stakeCred: '337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251',
257+
dbSyncAddr:
258+
'stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn',
259+
},
260+
},
261+
{
262+
description: 'Valid stake address type-15 (script)',
263+
input: 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5',
264+
network: 'mainnet',
265+
result: {
266+
prefix: 'stake',
267+
type: 'scriptHash',
268+
stakeCred: 'c37b1b5dc0669f1d3c61a6fddb2e8fde96be87b881c60bce8e8d542f',
269+
dbSyncAddr: 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5',
270+
},
271+
},
272+
{
273+
description: 'valid script addr (cip 19 test vector)',
274+
input: 'script1cda3khwqv60360rp5m7akt50m6ttapacs8rqhn5w342z7r35m37',
275+
network: 'mainnet',
276+
result: {
277+
prefix: 'script',
278+
type: 'scriptHash',
279+
stakeCred: 'c37b1b5dc0669f1d3c61a6fddb2e8fde96be87b881c60bce8e8d542f',
280+
dbSyncAddr: 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5',
281+
},
282+
},
283+
{
284+
// addr_test1qzamu40sglnsrylzv9jylekjmzgaqsg5v5z9u6yk3jpnnxjwck77fqu8deuumsvnazjnjhwasc2eetfqpa2pvygts78ssd5388
285+
description: 'valid stake_vkh',
286+
input: 'stake_vkh1fmzmmeyrsah8nnwpj0522w2amkrpt89dyq84g9s3pwrc7dqjnfu',
287+
network: 'preview',
288+
result: {
289+
prefix: 'stake_vkh',
290+
type: 'keyHash',
291+
stakeCred: '4ec5bde483876e79cdc193e8a5395ddd86159cad200f5416110b878f',
292+
dbSyncAddr:
293+
'stake_test1up8vt00yswrku7wdcxf73ffethwcv9vu45sq74qkzy9c0rc5lytmp',
294+
},
295+
},
296+
] as const;
297+
213298
export const convertStakeAddress = [
214299
{
215300
description: 'Valid onchain address',

0 commit comments

Comments
 (0)