Skip to content

Commit 363e67b

Browse files
feat: add blockfrost DRep provider
1 parent 113aefe commit 363e67b

File tree

10 files changed

+265
-9
lines changed

10 files changed

+265
-9
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BlockfrostClient } from '../blockfrost/BlockfrostClient';
2+
import { BlockfrostProvider } from '../blockfrost/BlockfrostProvider';
3+
import { Cardano, DRepInfo, DRepProvider, GetDRepInfoArgs, GetDRepsInfoArgs } from '@cardano-sdk/core';
4+
import { Logger } from 'ts-log';
5+
import type { Responses } from '@blockfrost/blockfrost-js';
6+
7+
export class BlockfrostDRepProvider extends BlockfrostProvider implements DRepProvider {
8+
constructor(client: BlockfrostClient, logger: Logger) {
9+
super(client, logger);
10+
}
11+
12+
async getDRepInfo({ id }: GetDRepInfoArgs): Promise<DRepInfo> {
13+
try {
14+
const cip105DRepId = Cardano.DRepID.toCip105DRepID(id); // Blockfrost only supports CIP-105 DRep IDs
15+
const response = await this.request<Responses['drep']>(`governance/dreps/${cip105DRepId.toString()}`);
16+
const amount = BigInt(response.amount);
17+
const activeEpoch = response.active_epoch ? Cardano.EpochNo(response.active_epoch) : undefined;
18+
const active = response.active;
19+
const hasScript = response.has_script;
20+
21+
return {
22+
active,
23+
activeEpoch,
24+
amount,
25+
hasScript,
26+
id
27+
};
28+
} catch (error) {
29+
this.logger.error('getDRep failed', id);
30+
throw this.toProviderError(error);
31+
}
32+
}
33+
34+
getDRepsInfo({ ids }: GetDRepsInfoArgs): Promise<DRepInfo[]> {
35+
return Promise.all(ids.map((id) => this.getDRepInfo({ id })));
36+
}
37+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './BlockfrostDRepProvider';

packages/cardano-services-client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './TxSubmitProvider';
44
export * from './StakePoolProvider';
55
export * from './UtxoProvider';
66
export * from './ChainHistoryProvider';
7+
export * from './DRepProvider';
78
export * from './NetworkInfoProvider';
89
export * from './RewardsProvider';
910
export * from './HandleProvider';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* eslint-disable sonarjs/no-duplicate-string */
2+
import { Cardano, DRepInfo } from '@cardano-sdk/core';
3+
import type { Responses } from '@blockfrost/blockfrost-js';
4+
5+
import { BlockfrostClient } from '../../src/blockfrost/BlockfrostClient';
6+
import { BlockfrostDRepProvider } from '../../src';
7+
import { logger } from '@cardano-sdk/util-dev';
8+
import { mockResponses } from './util';
9+
10+
describe('BlockfrostDRepProvider', () => {
11+
let request: jest.Mock;
12+
let provider: BlockfrostDRepProvider;
13+
14+
beforeEach(() => {
15+
request = jest.fn();
16+
const client = { request } as unknown as BlockfrostClient;
17+
provider = new BlockfrostDRepProvider(client, logger);
18+
});
19+
20+
describe('getDRep', () => {
21+
const mockedDRepId = Cardano.DRepID('drep15cfxz9exyn5rx0807zvxfrvslrjqfchrd4d47kv9e0f46uedqtc');
22+
const mockedAssetResponse = {
23+
active: true,
24+
active_epoch: 420,
25+
amount: '2000000',
26+
drep_id: 'drep15cfxz9exyn5rx0807zvxfrvslrjqfchrd4d47kv9e0f46uedqtc',
27+
has_script: true,
28+
hex: 'a61261172624e8333ceff098648d90f8e404e2e36d5b5f5985cbd35d'
29+
} as Responses['drep'];
30+
31+
test('has nft metadata', async () => {
32+
mockResponses(request, [
33+
[
34+
`governance/dreps/${mockedDRepId}`,
35+
{
36+
...mockedAssetResponse
37+
}
38+
]
39+
]);
40+
41+
const response = await provider.getDRepInfo({ id: mockedDRepId });
42+
43+
expect(response).toMatchObject<DRepInfo>({
44+
active: true,
45+
activeEpoch: Cardano.EpochNo(420),
46+
amount: 2_000_000n,
47+
hasScript: true,
48+
id: mockedDRepId
49+
});
50+
});
51+
});
52+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const mockResponses = (request: jest.Mock, responses: [string | RegExp, unknown][]) => {
2+
request.mockImplementation(async (endpoint: string) => {
3+
for (const [match, response] of responses) {
4+
if (typeof match === 'string') {
5+
if (match === endpoint) return response;
6+
} else if (match.test(endpoint)) {
7+
return response;
8+
}
9+
}
10+
throw new Error(`Not implemented/matched: ${endpoint}`);
11+
});
12+
};
Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1-
import { Address, AddressType } from './Address';
2-
import { OpaqueString, assertIsBech32WithPrefix, typedBech32 } from '@cardano-sdk/util';
1+
import * as BaseEncoding from '@scure/base';
2+
import { Address, AddressType, Credential, CredentialType } from './Address';
3+
import { Hash28ByteBase16 } from '@cardano-sdk/crypto';
4+
import { OpaqueString, typedBech32 } from '@cardano-sdk/util';
5+
6+
const MAX_BECH32_LENGTH_LIMIT = 1023;
7+
const CIP_105_DREP_ID_LENGTH = 28;
8+
const CIP_129_DREP_ID_LENGTH = 29;
9+
310
/** DRepID as bech32 string */
411
export type DRepID = OpaqueString<'DRepID'>;
512

6-
/**
7-
* @param {string} value DRepID as bech32 string
8-
* @throws InvalidStringError
9-
*/
10-
export const DRepID = (value: string): DRepID => typedBech32(value, ['drep']);
13+
// CIP-105 is deprecated, however we still need to support it since several providers and tooling
14+
// stills uses this format.
15+
export const DRepID = (value: string): DRepID => {
16+
try {
17+
return typedBech32(value, ['drep'], 47);
18+
} catch {
19+
return typedBech32(value, ['drep', 'drep_script'], 45);
20+
}
21+
};
1122

1223
DRepID.isValid = (value: string): boolean => {
1324
try {
14-
assertIsBech32WithPrefix(value, 'drep');
25+
DRepID(value);
1526
return true;
1627
} catch {
1728
return false;
@@ -25,3 +36,73 @@ DRepID.canSign = (value: string): boolean => {
2536
return false;
2637
}
2738
};
39+
40+
DRepID.cip105FromCredential = (credential: Credential): DRepID => {
41+
let prefix = 'drep';
42+
if (credential.type === CredentialType.ScriptHash) {
43+
prefix = 'drep_script';
44+
}
45+
46+
const words = BaseEncoding.bech32.toWords(Buffer.from(credential.hash, 'hex'));
47+
48+
return BaseEncoding.bech32.encode(prefix, words, MAX_BECH32_LENGTH_LIMIT) as DRepID;
49+
};
50+
51+
DRepID.cip129FromCredential = (credential: Credential): DRepID => {
52+
// The CIP-129 header is defined by 2 nibbles, where the first 4 bits represent the kind of governance credential
53+
// (CC Hot, CC Cold and DRep), and the last 4 bits are the credential type (offset by 2 to ensure that governance
54+
// identifiers remain distinct and are not inadvertently processed as addresses).
55+
let header = '22'; // DRep-PubKeyHash header in hex [00100010]
56+
if (credential.type === CredentialType.ScriptHash) {
57+
header = '23'; // DRep-ScriptHash header in hex [00100011]
58+
}
59+
60+
const cip129payload = `${header}${credential.hash}`;
61+
const words = BaseEncoding.bech32.toWords(Buffer.from(cip129payload, 'hex'));
62+
63+
return BaseEncoding.bech32.encode('drep', words, MAX_BECH32_LENGTH_LIMIT) as DRepID;
64+
};
65+
66+
DRepID.toCredential = (drepId: DRepID): Credential => {
67+
const { words } = BaseEncoding.bech32.decode(drepId, MAX_BECH32_LENGTH_LIMIT);
68+
const payload = BaseEncoding.bech32.fromWords(words);
69+
70+
if (payload.length !== CIP_105_DREP_ID_LENGTH && payload.length !== CIP_129_DREP_ID_LENGTH) {
71+
throw new Error('Invalid DRepID payload');
72+
}
73+
74+
if (payload.length === CIP_105_DREP_ID_LENGTH) {
75+
const isScriptHash = drepId.includes('drep_script');
76+
77+
return {
78+
hash: Hash28ByteBase16(Buffer.from(payload).toString('hex')),
79+
type: isScriptHash ? CredentialType.ScriptHash : CredentialType.KeyHash
80+
};
81+
}
82+
83+
// CIP-129
84+
const header = payload[0];
85+
const hash = payload.slice(1);
86+
const isDrepGovCred = (header & 0x20) === 0x20; // 0b00100000
87+
const isScriptHash = (header & 0x03) === 0x03; // 0b00000011
88+
89+
if (!isDrepGovCred) {
90+
throw new Error('Invalid governance credential type');
91+
}
92+
93+
return {
94+
hash: Hash28ByteBase16(Buffer.from(hash).toString('hex')),
95+
type: isScriptHash ? CredentialType.ScriptHash : CredentialType.KeyHash
96+
};
97+
};
98+
99+
// Use these if you need to ensure the ID is in a specific format.
100+
DRepID.toCip105DRepID = (drepId: DRepID): DRepID => {
101+
const credential = DRepID.toCredential(drepId);
102+
return DRepID.cip105FromCredential(credential);
103+
};
104+
105+
DRepID.toCip129DRepID = (drepId: DRepID): DRepID => {
106+
const credential = DRepID.toCredential(drepId);
107+
return DRepID.cip129FromCredential(credential);
108+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './types';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Cardano, Provider } from '../..';
2+
3+
export interface GetDRepInfoArgs {
4+
id: Cardano.DRepID;
5+
}
6+
7+
export interface GetDRepsInfoArgs {
8+
ids: Cardano.DRepID[];
9+
}
10+
11+
export interface DRepInfo {
12+
id: Cardano.DRepID;
13+
amount: Cardano.Lovelace;
14+
active: boolean;
15+
activeEpoch?: Cardano.EpochNo;
16+
hasScript: boolean;
17+
}
18+
19+
export interface DRepProvider extends Provider {
20+
getDRepInfo: (args: GetDRepInfoArgs) => Promise<DRepInfo>;
21+
getDRepsInfo: (args: GetDRepsInfoArgs) => Promise<DRepInfo[]>;
22+
}

packages/core/src/Provider/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './ChainHistoryProvider';
1010
export * from './providerFactory';
1111
export * from './types';
1212
export * from './HandleProvider';
13+
export * from './DRepProvider';

packages/core/test/Cardano/Address/DRepID.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,55 @@
1-
import { DRepID } from '../../../src/Cardano';
1+
import { Credential, CredentialType, DRepID } from '../../../src/Cardano';
22
import { InvalidStringError } from '@cardano-sdk/util';
33

4+
const CIP105_PUB_KEY_HASH_ID = 'drep15cfxz9exyn5rx0807zvxfrvslrjqfchrd4d47kv9e0f46uedqtc';
5+
const CIP129_PUB_KEY_HASH_ID = 'drep1y2npycghycjwsveualcfseydjruwgp8zudk4kh6esh9axhgfcvqjs';
6+
const KEY_HASH = 'a61261172624e8333ceff098648d90f8e404e2e36d5b5f5985cbd35d';
7+
8+
const CIP105_SCRIPT_HASH_ID = 'drep_script1rxdd99vu338y659qfg8nmpemdyhlsmaudgv4m4zdz7m5vz8uzt6';
9+
const CIP129_SCRIPT_HASH_ID = 'drep1yvve4554njxyun2s5p9q70v88d5jl7r0h34pjhw5f5tmw3sjtrutp';
10+
const SCRIPT_HASH = '199ad2959c8c4e4d50a04a0f3d873b692ff86fbc6a195dd44d17b746';
11+
12+
const pubKeyHashCredential = {
13+
hash: KEY_HASH,
14+
type: CredentialType.KeyHash
15+
} as Credential;
16+
17+
const scriptHashCredential = {
18+
hash: SCRIPT_HASH,
19+
type: CredentialType.ScriptHash
20+
} as Credential;
21+
422
describe('Cardano/Address/DRepID', () => {
23+
it('can parse both CIP-105 and CIP-129 pub key hash DRep IDs', () => {
24+
expect(DRepID.toCredential(DRepID(CIP105_PUB_KEY_HASH_ID))).toEqual(pubKeyHashCredential);
25+
expect(DRepID.toCredential(DRepID(CIP129_PUB_KEY_HASH_ID))).toEqual(pubKeyHashCredential);
26+
});
27+
28+
it('can parse both CIP-105 and CIP-129 script hash DRep IDs', () => {
29+
expect(DRepID.toCredential(DRepID(CIP105_SCRIPT_HASH_ID))).toEqual(scriptHashCredential);
30+
expect(DRepID.toCredential(DRepID(CIP129_SCRIPT_HASH_ID))).toEqual(scriptHashCredential);
31+
});
32+
33+
it('can create CIP-105 DRep IDs from credentials', () => {
34+
expect(DRepID.cip105FromCredential(pubKeyHashCredential)).toEqual(CIP105_PUB_KEY_HASH_ID);
35+
expect(DRepID.cip105FromCredential(scriptHashCredential)).toEqual(CIP105_SCRIPT_HASH_ID);
36+
});
37+
38+
it('can create CIP-129 DRep IDs from credentials', () => {
39+
expect(DRepID.cip129FromCredential(pubKeyHashCredential)).toEqual(CIP129_PUB_KEY_HASH_ID);
40+
expect(DRepID.cip129FromCredential(scriptHashCredential)).toEqual(CIP129_SCRIPT_HASH_ID);
41+
});
42+
43+
it('can convert CIP-105 DRep IDs to CIP-129 DRep IDs', () => {
44+
expect(DRepID.toCip129DRepID(DRepID.cip105FromCredential(pubKeyHashCredential))).toEqual(CIP129_PUB_KEY_HASH_ID);
45+
expect(DRepID.toCip129DRepID(DRepID.cip105FromCredential(scriptHashCredential))).toEqual(CIP129_SCRIPT_HASH_ID);
46+
});
47+
48+
it('can convert CIP-129 DRep IDs to CIP-105 DRep IDs', () => {
49+
expect(DRepID.toCip105DRepID(DRepID.cip129FromCredential(pubKeyHashCredential))).toEqual(CIP105_PUB_KEY_HASH_ID);
50+
expect(DRepID.toCip105DRepID(DRepID.cip129FromCredential(scriptHashCredential))).toEqual(CIP105_SCRIPT_HASH_ID);
51+
});
52+
553
it('DRepID() accepts a valid bech32 string with drep as prefix', () => {
654
expect(() => DRepID('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz')).not.toThrow();
755
});

0 commit comments

Comments
 (0)