Skip to content

Commit a614361

Browse files
feat(1730): Allowance list implementation (#1899)
Signed-off-by: matevszm <mateusz.marcinkowski@blockydevs.com> Co-authored-by: mmyslblocky <michal.myslinski@blockydevs.com>
1 parent 55d5e0a commit a614361

29 files changed

Lines changed: 2248 additions & 2 deletions

src/__tests__/mocks/mocks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,12 @@ export const createMirrorNodeMock =
349349
getAccountOrThrow: jest.fn(),
350350
getAccount: jest.fn(),
351351
getAccountTokenBalances: jest.fn(),
352+
getHbarAllowances: jest.fn(),
353+
getAllHbarAllowances: jest.fn(),
354+
getTokenAllowances: jest.fn(),
355+
getAllTokenAllowances: jest.fn(),
356+
getNftAllowances: jest.fn(),
357+
getAllNftAllowances: jest.fn(),
352358
getAccountNfts: jest.fn(),
353359
getAccounts: jest.fn(),
354360
getTopicMessage: jest.fn(),
@@ -607,6 +613,12 @@ export const makeArgs = (
607613
getAccountOrThrow: jest.fn(),
608614
getAccount: jest.fn(),
609615
getAccountTokenBalances: jest.fn(),
616+
getHbarAllowances: jest.fn(),
617+
getAllHbarAllowances: jest.fn(),
618+
getTokenAllowances: jest.fn(),
619+
getAllTokenAllowances: jest.fn(),
620+
getNftAllowances: jest.fn(),
621+
getAllNftAllowances: jest.fn(),
610622
getAccountNfts: jest.fn(),
611623
getAccounts: jest.fn(),
612624
getTopicMessage: jest.fn(),

src/core/schemas/common-schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@ export const AccountReferenceObjectSchema = z
393393
})
394394
.describe('Account identifier (ID, EVM address, or alias)');
395395

396+
export type AccountReference = z.infer<typeof AccountReferenceObjectSchema>;
397+
396398
/**
397399
* Parsed token reference as a discriminated object by type (entity ID or alias).
398400
*/

src/core/services/mirrornode/__tests__/unit/hedera-mirrornode-service.test.ts

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
createMockContractInfo,
99
makeNetworkMock,
1010
} from '@/__tests__/mocks/mocks';
11-
import { NetworkError, NotFoundError } from '@/core/errors';
11+
import { NetworkError, NotFoundError, ValidationError } from '@/core/errors';
1212
import { HederaMirrornodeServiceDefaultImpl } from '@/core/services/mirrornode/hedera-mirrornode-service';
1313
import {
1414
AccountBalanceOperator,
@@ -395,6 +395,216 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
395395
});
396396
});
397397

398+
describe('allowance endpoints', () => {
399+
it('should fetch HBAR allowances with correct URL', async () => {
400+
const { service } = setupService();
401+
const mockResponse = {
402+
allowances: [
403+
{
404+
owner: TEST_ACCOUNT_ID,
405+
spender: '0.0.5678',
406+
amount: 100000000,
407+
},
408+
],
409+
links: { next: null },
410+
};
411+
(global.fetch as jest.Mock).mockResolvedValue({
412+
ok: true,
413+
json: jest.fn().mockResolvedValue(mockResponse),
414+
});
415+
416+
const result = await service.getHbarAllowances(TEST_ACCOUNT_ID);
417+
418+
expect(global.fetch).toHaveBeenCalledWith(
419+
`${TESTNET_API_URL}/accounts/${TEST_ACCOUNT_ID}/allowances/crypto`,
420+
);
421+
expect(result.allowances).toHaveLength(1);
422+
expect(result.allowances[0].amount).toBe(100000000n);
423+
});
424+
425+
it('should fetch token allowances with pagination params', async () => {
426+
const { service } = setupService();
427+
const mockResponse = {
428+
allowances: [
429+
{
430+
owner: TEST_ACCOUNT_ID,
431+
spender: '0.0.5678',
432+
token_id: TEST_TOKEN_ID,
433+
amount: '1000',
434+
},
435+
],
436+
links: { next: '/api/v1/accounts/0.0.1234/allowances/tokens?cursor=2' },
437+
};
438+
(global.fetch as jest.Mock).mockResolvedValue({
439+
ok: true,
440+
json: jest.fn().mockResolvedValue(mockResponse),
441+
});
442+
443+
const result = await service.getTokenAllowances(TEST_ACCOUNT_ID, {
444+
limit: 100,
445+
cursor: 'abc123',
446+
});
447+
448+
expect(global.fetch).toHaveBeenCalledWith(
449+
`${TESTNET_API_URL}/accounts/${TEST_ACCOUNT_ID}/allowances/tokens?limit=100&cursor=abc123`,
450+
);
451+
expect(result.links?.next).toBe(
452+
'/api/v1/accounts/0.0.1234/allowances/tokens?cursor=2',
453+
);
454+
expect(result.allowances[0].amount).toBe(1000n);
455+
});
456+
457+
it('should reject unsafe numeric allowance amounts', async () => {
458+
const { service } = setupService();
459+
(global.fetch as jest.Mock).mockResolvedValue({
460+
ok: true,
461+
json: jest.fn().mockResolvedValue({
462+
allowances: [
463+
{
464+
owner: TEST_ACCOUNT_ID,
465+
spender: '0.0.5678',
466+
amount: Number.MAX_SAFE_INTEGER + 1,
467+
},
468+
],
469+
links: { next: null },
470+
}),
471+
});
472+
473+
await expect(service.getHbarAllowances(TEST_ACCOUNT_ID)).rejects.toThrow(
474+
ValidationError,
475+
);
476+
});
477+
478+
it('should fetch all HBAR allowance pages', async () => {
479+
const { service } = setupService();
480+
(global.fetch as jest.Mock)
481+
.mockResolvedValueOnce({
482+
ok: true,
483+
json: jest.fn().mockResolvedValue({
484+
allowances: [
485+
{ owner: TEST_ACCOUNT_ID, spender: '0.0.5678', amount: '1' },
486+
],
487+
links: {
488+
next: '/api/v1/accounts/0.0.1234/allowances/crypto?cursor=page2',
489+
},
490+
}),
491+
})
492+
.mockResolvedValueOnce({
493+
ok: true,
494+
json: jest.fn().mockResolvedValue({
495+
allowances: [
496+
{ owner: TEST_ACCOUNT_ID, spender: '0.0.9999', amount: 2 },
497+
],
498+
links: { next: null },
499+
}),
500+
});
501+
502+
const result = await service.getAllHbarAllowances(TEST_ACCOUNT_ID);
503+
504+
expect(result.allowances.map((allowance) => allowance.amount)).toEqual([
505+
1n,
506+
2n,
507+
]);
508+
expect(global.fetch).toHaveBeenNthCalledWith(
509+
1,
510+
`${TESTNET_API_URL}/accounts/${TEST_ACCOUNT_ID}/allowances/crypto?limit=100`,
511+
);
512+
expect(global.fetch).toHaveBeenNthCalledWith(
513+
2,
514+
`${TESTNET_API_URL}/accounts/${TEST_ACCOUNT_ID}/allowances/crypto?limit=100&cursor=page2`,
515+
);
516+
});
517+
518+
it('should throw NetworkError on repeated allowance cursor', async () => {
519+
const { service } = setupService();
520+
(global.fetch as jest.Mock)
521+
.mockResolvedValueOnce({
522+
ok: true,
523+
json: jest.fn().mockResolvedValue({
524+
allowances: [],
525+
links: {
526+
next: '/api/v1/accounts/0.0.1234/allowances/tokens?cursor=page2',
527+
},
528+
}),
529+
})
530+
.mockResolvedValueOnce({
531+
ok: true,
532+
json: jest.fn().mockResolvedValue({
533+
allowances: [],
534+
links: {
535+
next: '/api/v1/accounts/0.0.1234/allowances/tokens?cursor=page2',
536+
},
537+
}),
538+
});
539+
540+
await expect(
541+
service.getAllTokenAllowances(TEST_ACCOUNT_ID),
542+
).rejects.toThrow(NetworkError);
543+
});
544+
545+
it('should throw NetworkError when allowance pagination limit is exceeded', async () => {
546+
const { service } = setupService();
547+
(global.fetch as jest.Mock).mockResolvedValue({
548+
ok: true,
549+
json: jest.fn().mockImplementation(() =>
550+
Promise.resolve({
551+
allowances: [],
552+
links: {
553+
next: `/api/v1/accounts/0.0.1234/allowances/nfts?cursor=${String(
554+
(global.fetch as jest.Mock).mock.calls.length,
555+
)}`,
556+
},
557+
}),
558+
),
559+
});
560+
561+
await expect(
562+
service.getAllNftAllowances(TEST_ACCOUNT_ID),
563+
).rejects.toThrow(NetworkError);
564+
expect(global.fetch).toHaveBeenCalledTimes(100);
565+
});
566+
567+
it('should fetch NFT allowances and parse all-serial approval', async () => {
568+
const { service } = setupService();
569+
const mockResponse = {
570+
allowances: [
571+
{
572+
owner: TEST_ACCOUNT_ID,
573+
spender: '0.0.5678',
574+
token_id: TEST_TOKEN_ID,
575+
serial_number: null,
576+
approved_for_all: true,
577+
},
578+
],
579+
links: { next: null },
580+
};
581+
(global.fetch as jest.Mock).mockResolvedValue({
582+
ok: true,
583+
json: jest.fn().mockResolvedValue(mockResponse),
584+
});
585+
586+
const result = await service.getNftAllowances(TEST_ACCOUNT_ID);
587+
588+
expect(global.fetch).toHaveBeenCalledWith(
589+
`${TESTNET_API_URL}/accounts/${TEST_ACCOUNT_ID}/allowances/nfts`,
590+
);
591+
expect(result.allowances[0].approved_for_all).toBe(true);
592+
});
593+
594+
it('should throw NotFoundError for allowance HTTP 404', async () => {
595+
const { service } = setupService();
596+
(global.fetch as jest.Mock).mockResolvedValue({
597+
ok: false,
598+
status: 404,
599+
statusText: 'Not Found',
600+
});
601+
602+
await expect(service.getHbarAllowances(TEST_ACCOUNT_ID)).rejects.toThrow(
603+
NotFoundError,
604+
);
605+
});
606+
});
607+
398608
describe('getAccounts', () => {
399609
it('should fetch accounts without params and return mapped DTO', async () => {
400610
const { service } = setupService();

src/core/services/mirrornode/hedera-mirrornode-service.interface.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import type {
1111
ExchangeRateResponse,
1212
GetAccountsQueryParams,
1313
GetAccountsResponse,
14+
HbarAllowancesResponse,
15+
MirrorNodePageParams,
16+
NftAllowancesResponse,
1417
NftInfo,
1518
ScheduleInfo,
1619
TokenAirdropsResponse,
20+
TokenAllowancesResponse,
1721
TokenBalancesResponse,
1822
TokenInfo,
1923
TopicInfo,
@@ -42,6 +46,36 @@ export interface HederaMirrornodeService {
4246
tokenId?: string,
4347
): Promise<TokenBalancesResponse>;
4448

49+
/**
50+
* Get HBAR allowances granted by an account
51+
*/
52+
getHbarAllowances(
53+
accountId: string,
54+
params?: MirrorNodePageParams,
55+
): Promise<HbarAllowancesResponse>;
56+
57+
getAllHbarAllowances(accountId: string): Promise<HbarAllowancesResponse>;
58+
59+
/**
60+
* Get fungible token allowances granted by an account
61+
*/
62+
getTokenAllowances(
63+
accountId: string,
64+
params?: MirrorNodePageParams,
65+
): Promise<TokenAllowancesResponse>;
66+
67+
getAllTokenAllowances(accountId: string): Promise<TokenAllowancesResponse>;
68+
69+
/**
70+
* Get NFT allowances granted by an account
71+
*/
72+
getNftAllowances(
73+
accountId: string,
74+
params?: MirrorNodePageParams,
75+
): Promise<NftAllowancesResponse>;
76+
77+
getAllNftAllowances(accountId: string): Promise<NftAllowancesResponse>;
78+
4579
/**
4680
* List account entities on network with optional filters and pagination
4781
*/

0 commit comments

Comments
 (0)