Skip to content

Commit 0492223

Browse files
Merge pull request #1534 from input-output-hk/feat/lw-11756-add-drep-status-provider
Feat/lw 11756 add drep status provider
2 parents 7198c54 + 55c37bb commit 0492223

File tree

15 files changed

+399
-50
lines changed

15 files changed

+399
-50
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';

packages/cardano-services-client/test/AssetInfoProvider/BlockfrostAssetProvider.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Responses } from '@blockfrost/blockfrost-js';
55
import { BlockfrostAssetProvider } from '../../src';
66
import { BlockfrostClient } from '../../src/blockfrost/BlockfrostClient';
77
import { logger } from '@cardano-sdk/util-dev';
8-
import { mockResponses } from './util';
8+
import { mockResponses } from '../util';
99

1010
describe('BlockfrostAssetProvider', () => {
1111
let request: jest.Mock;

packages/cardano-services-client/test/AssetInfoProvider/util.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.
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('getDRepInfo', 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+
});

packages/cardano-services-client/test/util.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,16 @@ export const getBobHandleProviderResponse = {
6060
policyId: Cardano.PolicyId('50fdcdbfa3154db86a87e4b5697ae30d272e0bbcfa8122efd3e301cb'),
6161
profilePic: Asset.Uri('ipfs://zrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3yd1')
6262
};
63+
64+
export const mockResponses = (request: jest.Mock, responses: [string | RegExp, unknown][]) => {
65+
request.mockImplementation(async (endpoint: string) => {
66+
for (const [match, response] of responses) {
67+
if (typeof match === 'string') {
68+
if (match === endpoint) return response;
69+
} else if (match.test(endpoint)) {
70+
return response;
71+
}
72+
}
73+
throw new Error(`Not implemented/matched: ${endpoint}`);
74+
});
75+
};

packages/cardano-services/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
"wait-on": "^6.0.1"
9292
},
9393
"dependencies": {
94-
"@blockfrost/blockfrost-js": "^5.5.0",
94+
"@blockfrost/blockfrost-js": "^5.7.0",
9595
"@cardano-sdk/cardano-services-client": "workspace:~",
9696
"@cardano-sdk/core": "workspace:~",
9797
"@cardano-sdk/crypto": "workspace:~",
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';

0 commit comments

Comments
 (0)