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
66 changes: 66 additions & 0 deletions __tests__/integration/fullnode-specific/get-balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
Comment thread
tuliomir marked this conversation as resolved.
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Fullnode-facade getBalance() tests.
*
* Tests that rely on fullnode-only APIs or behavior (e.g. no-arg getBalance()
* rejection, nonexistent token returning a zero-balance entry).
*
* Shared getBalance() tests live in `shared/get-balance.test.ts`.
*/

import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper';
import { getRandomInt } from '../utils/core.util';
import { createTokenHelper, generateWalletHelper, stopAllWallets } from '../helpers/wallet.helper';

const fakeTokenUid = '008a19f84f2ae284f19bf3d03386c878ddd15b8b0b604a3a3539aa9d714686e1';

describe('[Fullnode] getBalance', () => {
afterEach(async () => {
await stopAllWallets();
});

it('should reject when tokenUid is not provided', async () => {
const hWallet = await generateWalletHelper();
await expect(hWallet.getBalance()).rejects.toThrow();
});
Comment thread
pedroferreira1 marked this conversation as resolved.

it('should return zero balance for a nonexistent token', async () => {
const hWallet = await generateWalletHelper();

const emptyBalance = await hWallet.getBalance(fakeTokenUid);
expect(emptyBalance).toHaveLength(1);
expect(emptyBalance[0]).toMatchObject({
token: { id: fakeTokenUid },
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});
});

it('should not show custom token balance on a different wallet', async () => {
const hWallet = await generateWalletHelper();

await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n);
const newTokenAmount = BigInt(getRandomInt(1000, 10));
const { hash: tokenUid } = await createTokenHelper(
hWallet,
'BalanceToken',
'BAT',
newTokenAmount
);

const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton();
const genesisTknBalance = await gWallet.getBalance(tokenUid);
expect(genesisTknBalance).toHaveLength(1);
expect(genesisTknBalance[0]).toMatchObject({
token: { id: tokenUid },
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});
});
});
Comment on lines +45 to +66
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't it in the shared tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: It's blocked by a wallet-service inconsistency (issue #397). Once the wallet-service returns a zero-balance entry instead of an empty array for unknown tokens, this test can be promoted to the shared suite.

--

The fullnode facade returns a zero-balance entry (line 60-64 of the test) when you ask for the balance of a token the wallet has never interacted with — the fullnode always synthesizes a response. The wallet-service facade, on the other hand, has a known bug where it returns an empty array for tokens the wallet doesn't hold (see the two FIXME(wallet-service) comments at lines 62-64 and 79-80 of service-specific/get-balance.test.ts, referencing issue #397).

Since the two facades don't agree on the return shape for "token not on this wallet," the test can't be written with a single shared assertion. Moving it to the shared suite would either require:

  1. Different expectations per adapter (defeating the purpose of shared tests), or
  2. The wallet-service bug to be fixed first so both facades return a consistent zero-balance entry.

The test was intentionally kept fullnode-specific until the wallet-service behavior converges.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you test it? We had this problem and Abadesso fixed it in the wallet service. It was implemented December last year

image

85 changes: 1 addition & 84 deletions __tests__/integration/hathorwallet_facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,90 +457,7 @@ describe('addresses methods', () => {
});
});

describe('getBalance', () => {
afterEach(async () => {
await stopAllWallets();
await GenesisWalletHelper.clearListeners();
});

it('should get the balance for the HTR token', async () => {
const hWallet = await generateWalletHelper();

// Validating that the token uid parameter is mandatory.
await expect(hWallet.getBalance()).rejects.toThrow();

// Validating the return array has one entry on an empty wallet
const balance = await hWallet.getBalance(NATIVE_TOKEN_UID);
expect(balance).toHaveLength(1);
expect(balance[0]).toMatchObject({
token: { id: NATIVE_TOKEN_UID },
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});

// Generating one transaction to validate its effects
const injectedValue = BigInt(getRandomInt(10, 2));
await GenesisWalletHelper.injectFunds(
hWallet,
await hWallet.getAddressAtIndex(0),
injectedValue
);

// Validating the transaction effects
const balance1 = await hWallet.getBalance(NATIVE_TOKEN_UID);
expect(balance1[0]).toMatchObject({
balance: { unlocked: injectedValue, locked: 0n },
transactions: expect.any(Number),
// transactions: 1, // TODO: The amount of transactions is often 2 but should be 1. Ref #397
});

// Transferring tokens inside the wallet should not change the balance
const tx1 = await hWallet.sendTransaction(await hWallet.getAddressAtIndex(1), 2n);
await waitForTxReceived(hWallet, tx1.hash);
const balance2 = await hWallet.getBalance(NATIVE_TOKEN_UID);
expect(balance2[0].balance).toEqual(balance1[0].balance);
});

it('should get the balance for a custom token', async () => {
const hWallet = await generateWalletHelper();

// Validating results for a nonexistant token
const emptyBalance = await hWallet.getBalance(fakeTokenUid);
expect(emptyBalance).toHaveLength(1);
expect(emptyBalance[0]).toMatchObject({
token: { id: fakeTokenUid },
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});

// Creating a new custom token
await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n);
const newTokenAmount = BigInt(getRandomInt(1000, 10));
const { hash: tokenUid } = await createTokenHelper(
hWallet,
'BalanceToken',
'BAT',
newTokenAmount
);

const tknBalance = await hWallet.getBalance(tokenUid);
expect(tknBalance[0]).toMatchObject({
balance: { unlocked: newTokenAmount, locked: 0n },
transactions: expect.any(Number),
// transactions: 1, // TODO: The amount of transactions is often 8 but should be 1. Ref #397
});

// Validating that a different wallet (genesis) has no access to this token
const { hWallet: gWallet } = await GenesisWalletHelper.getSingleton();
const genesisTknBalance = await gWallet.getBalance(tokenUid);
expect(genesisTknBalance).toHaveLength(1);
expect(genesisTknBalance[0]).toMatchObject({
token: { id: tokenUid },
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});
});
});
// getBalance tests moved to shared/get-balance.test.ts and fullnode-specific/get-balance.test.ts

describe('getFullHistory', () => {
afterEach(async () => {
Expand Down
83 changes: 83 additions & 0 deletions __tests__/integration/service-specific/get-balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Service-facade getBalance() tests.
*
* Tests for service-only behavior: no-arg getBalance() (returns all tokens),
* not-ready wallet rejection, and skipped empty-wallet bugs.
*
* Shared getBalance() tests live in `shared/get-balance.test.ts`.
*/

import type { HathorWalletServiceWallet } from '../../../src';
import { NATIVE_TOKEN_UID } from '../../../src/constants';
import { buildWalletInstance, emptyWallet } from '../helpers/service-facade.helper';
import { ServiceWalletTestAdapter } from '../adapters/service.adapter';

const adapter = new ServiceWalletTestAdapter();

beforeAll(async () => {
await adapter.suiteSetup();
});

afterAll(async () => {
await adapter.suiteTeardown();
});

describe('[Service] getBalance', () => {
let wallet: HathorWalletServiceWallet;

afterEach(async () => {
if (wallet) {
try {
await wallet.stop({ cleanStorage: true });
} catch {
// Wallet may already be stopped
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's good to see if the error that is being raised is related to the wallet stopping. Otherwise it could hide something.

}
}
});

it('should return balance for a funded wallet using no-arg getBalance()', async () => {
const { wallet: w } = await adapter.createWallet();
wallet = w as unknown as HathorWalletServiceWallet;

const addr = await w.getAddressAtIndex(0);
expect(addr).toBeDefined();
await adapter.injectFunds(w, addr as string, 1n);

const balances = await wallet.getBalance();
expect(Array.isArray(balances)).toBe(true);
expect(balances.length).toBeGreaterThanOrEqual(1);

const htrBalance = balances.find(b => b.token.id === NATIVE_TOKEN_UID);
expect(htrBalance).toBeDefined();
expect(typeof htrBalance?.balance).toBe('object');
});

// FIXME(wallet-service): getBalance() on an empty wallet should return a single
// entry with 0 balance for the native token, but currently returns an empty array.
// Ref: https://github.com/HathorNetwork/hathor-wallet-lib/issues/397
it.skip('should return balance array for empty wallet', async () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had fixed that and the next one, not sure why it's not working

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried removing all the skips but the tests just failed. Here's a small report on it:

--

  1. should return balance array for empty wallet — wallet-service still returns an empty array (length: 0) instead of one entry with zero balance (length: 1)

  2. should return balance for specific token when token parameter is provided — similarly, the assertion at line 83 expects balances.length to be 1, but the first test at line 69 already shows it's 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of this investigation above I ended up identifying another specific test that could to go Shared.

It's not perfect since it cannot validate the value, but it's better than a skipped specific test anyway. Done on 08f37ef

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tuliomir did you run with a updated image of wallet-sevice? This is a known issue of fixes not being applied to the integration tests due to the usage of stale container images.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just re-created the images locally with the HEAD at HathorNetwork/hathor-wallet-service@947e90f . This has still failed.

The wallet service still does not synthesize a "zero balance" record for HTR when there are no transactions.

Could we solve this in a future PR so that we can advance with the Shared Tests suite construction? Future improvements will be easier to implement when we have that in place.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. Open an issue for it pls

({ wallet } = buildWalletInstance({ words: emptyWallet.words }));
await wallet.start({ pinCode: adapter.defaultPinCode, password: adapter.defaultPassword });

const balances = await wallet.getBalance();

expect(Array.isArray(balances)).toBe(true);
expect(balances.length).toStrictEqual(1);

const htrBalance = balances.find(b => b.token.id === NATIVE_TOKEN_UID);
expect(htrBalance).toBeDefined();
expect(htrBalance?.balance).toBe(0n);
});

it('should throw error when wallet is not ready', async () => {
const { wallet: notReadyWallet } = buildWalletInstance({ words: emptyWallet.words });
await expect(notReadyWallet.getBalance()).rejects.toThrow('Wallet not ready');
});
});
151 changes: 151 additions & 0 deletions __tests__/integration/shared/get-balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Shared getBalance() tests.
*
* Validates balance query behavior that is common to both the fullnode
* ({@link HathorWallet}) and wallet-service ({@link HathorWalletServiceWallet})
* facades.
*
* Facade-specific tests live in:
* - `fullnode-specific/get-balance.test.ts`
* - `service-specific/get-balance.test.ts`
*/

import type { IWalletTestAdapter } from '../adapters/types';
import { NATIVE_TOKEN_UID } from '../../../src/constants';
import { getRandomInt } from '../utils/core.util';
import { FullnodeWalletTestAdapter } from '../adapters/fullnode.adapter';
import { ServiceWalletTestAdapter } from '../adapters/service.adapter';

const adapters: IWalletTestAdapter[] = [
new FullnodeWalletTestAdapter(),
new ServiceWalletTestAdapter(),
];

describe.each(adapters)('[Shared] getBalance — $name', adapter => {
beforeAll(async () => {
await adapter.suiteSetup();
});

afterAll(async () => {
await adapter.suiteTeardown();
});

it('should return zero balance on an empty wallet', async () => {
const { wallet } = await adapter.createWallet();

try {
const balance = await wallet.getBalance(NATIVE_TOKEN_UID);
expect(balance).toHaveLength(1);
expect(balance[0]).toMatchObject({
token: { id: NATIVE_TOKEN_UID },
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});
} finally {
await adapter.stopWallet(wallet);
}
});

it('should return full balance shape for a specific token on an empty wallet', async () => {
const { wallet } = await adapter.createWallet();

try {
const balance = await wallet.getBalance(NATIVE_TOKEN_UID);
expect(balance).toHaveLength(1);
expect(balance[0]).toMatchObject({
token: {
id: NATIVE_TOKEN_UID,
name: expect.any(String),
symbol: expect.any(String),
version: expect.any(Number),
},
balance: { unlocked: 0n, locked: 0n },
transactions: 0,
});

// Authorities shape is present but the value type differs between facades
// (fullnode returns 0n, wallet-service returns false), so we check structure only
const { tokenAuthorities } = balance[0];
expect(tokenAuthorities).toBeDefined();
expect(tokenAuthorities).toHaveProperty('unlocked.mint');
expect(tokenAuthorities).toHaveProperty('unlocked.melt');
expect(tokenAuthorities).toHaveProperty('locked.mint');
expect(tokenAuthorities).toHaveProperty('locked.melt');
} finally {
await adapter.stopWallet(wallet);
}
});

it('should reflect injected funds in balance', async () => {
const { wallet } = await adapter.createWallet();

try {
const injectedValue = BigInt(getRandomInt(10, 2));
const addr = await wallet.getAddressAtIndex(0);
expect(addr).toBeDefined();
await adapter.injectFunds(wallet, addr!, injectedValue);

const balance = await wallet.getBalance(NATIVE_TOKEN_UID);
expect(balance[0]).toMatchObject({
balance: { unlocked: injectedValue, locked: 0n },
});
} finally {
await adapter.stopWallet(wallet);
}
});

it('should not change balance after internal transfer', async () => {
const { wallet } = await adapter.createWallet();

try {
const injectedValue = BigInt(getRandomInt(10, 2));
const addr = await wallet.getAddressAtIndex(0);
expect(addr).toBeDefined();
await adapter.injectFunds(wallet, addr!, injectedValue);

const balanceBefore = await wallet.getBalance(NATIVE_TOKEN_UID);

const tx = await wallet.sendTransaction(await wallet.getAddressAtIndex(1), 2n, {
pinCode: adapter.defaultPinCode,
});
await adapter.waitForTx(wallet, tx.hash!);

const balanceAfter = await wallet.getBalance(NATIVE_TOKEN_UID);
expect(balanceAfter[0].balance).toEqual(balanceBefore[0].balance);
} finally {
await adapter.stopWallet(wallet);
}
});

it('should get the balance for a custom token', async () => {
const { wallet } = await adapter.createWallet();

try {
// Creating a new custom token
const addr = await wallet.getAddressAtIndex(0);
expect(addr).toBeDefined();
await adapter.injectFunds(wallet, addr!, 10n);

const newTokenAmount = BigInt(getRandomInt(1000, 10));
const newToken = await wallet.createNewToken('BalanceToken', 'BAT', newTokenAmount, {
pinCode: adapter.defaultPinCode,
});
await adapter.waitForTx(wallet, newToken.hash!);

const tknBalance = await wallet.getBalance(newToken.hash!);
expect(tknBalance[0]).toMatchObject({
balance: { unlocked: newTokenAmount, locked: 0n },
transactions: expect.any(Number),
});
} finally {
await adapter.stopWallet(wallet);
}
});
});
Loading
Loading