Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: send tokens #30

Open
wants to merge 6 commits into
base: feat/parameter-validation
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* 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.
*/

import { HathorWallet } from '@hathor/wallet-lib';
import { sendTransaction } from '../../src/rpcMethods/sendTransaction';
import {
RpcMethods,
SendTransactionRpcRequest,
TriggerTypes,
TriggerResponseTypes,
RpcResponseTypes,
} from '../../src/types';
import {
InvalidParamsError,
PromptRejectedError,
SendTransactionError,
InsufficientFundsError,
DifferentNetworkError,
} from '../../src/errors';

describe('sendTransaction', () => {
let rpcRequest: SendTransactionRpcRequest;
let wallet: jest.Mocked<HathorWallet>;
let promptHandler: jest.Mock;
let sendTransactionMock: jest.Mock;

beforeEach(() => {
// Setup basic request
rpcRequest = {
method: RpcMethods.SendTransaction,
params: {
network: 'testnet',
outputs: [{
address: 'testAddress',
value: '100',
token: '00',
}],
inputs: [{
txId: 'testTxId',
index: 0,
}],
changeAddress: 'changeAddress',
},
};

// Mock wallet
sendTransactionMock = jest.fn();
wallet = {
getNetwork: jest.fn().mockReturnValue('testnet'),
sendManyOutputsSendTransaction: jest.fn().mockResolvedValue({
prepareTxData: jest.fn().mockResolvedValue({
inputs: [{
txId: 'testTxId',
index: 0,
value: 100,
address: 'testAddress',
token: '00',
}],
}),
run: sendTransactionMock,
}),
} as unknown as jest.Mocked<HathorWallet>;

// Mock prompt handler
promptHandler = jest.fn();
});

it('should successfully send a transaction', async () => {
const pinCode = '1234';
const txResponse = { hash: 'txHash123' };

// Mock prompt responses
promptHandler
// Transaction confirmation prompt
.mockResolvedValueOnce({
type: TriggerResponseTypes.SendTransactionConfirmationResponse,
data: { accepted: true },
})
// PIN confirmation prompt
.mockResolvedValueOnce({
type: TriggerResponseTypes.PinRequestResponse,
data: { accepted: true, pinCode },
});

sendTransactionMock.mockResolvedValue(txResponse);

const response = await sendTransaction(rpcRequest, wallet, {}, promptHandler);

expect(response).toEqual({
type: RpcResponseTypes.SendTransactionResponse,
response: txResponse,
});

// Verify all prompts were shown in correct order
expect(promptHandler).toHaveBeenCalledTimes(4); // Confirmation, PIN, Loading, LoadingFinished
expect(promptHandler).toHaveBeenNthCalledWith(1, {
type: TriggerTypes.SendTransactionConfirmationPrompt,
method: rpcRequest.method,
data: {
outputs: [{
address: 'testAddress',
value: BigInt(100),
token: '00',
}],
inputs: [{
txId: 'testTxId',
index: 0,
value: 100,
address: 'testAddress',
token: '00',
}],
changeAddress: 'changeAddress',
},
}, {});
expect(promptHandler).toHaveBeenNthCalledWith(2, {
type: TriggerTypes.PinConfirmationPrompt,
method: rpcRequest.method,
}, {});
});

it('should handle data outputs correctly', async () => {
rpcRequest.params.outputs = [{
type: 'data',
value: '100',
data: ['test data'],
}];

promptHandler
.mockResolvedValueOnce({
type: TriggerResponseTypes.SendTransactionConfirmationResponse,
data: { accepted: true },
})
.mockResolvedValueOnce({
type: TriggerResponseTypes.PinRequestResponse,
data: { accepted: true, pinCode: '1234' },
});

sendTransactionMock.mockResolvedValue({ hash: 'txHash123' });

await sendTransaction(rpcRequest, wallet, {}, promptHandler);

// Verify data output was transformed correctly
expect(wallet.sendManyOutputsSendTransaction).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
type: 'data',
value: BigInt(1),
token: '00',
data: ['test data'],
}),
]),
expect.any(Object),
);
});

it('should throw InvalidParamsError for invalid request parameters', async () => {
// Invalid request with missing required fields
const invalidRequest = {
method: RpcMethods.SendTransaction,
params: {
network: '', // Invalid: empty string
outputs: [], // Invalid: empty array
},
} as SendTransactionRpcRequest;

await expect(sendTransaction(invalidRequest, wallet, {}, promptHandler))
.rejects
.toThrow(InvalidParamsError);

expect(promptHandler).not.toHaveBeenCalled();
});

it('should throw PromptRejectedError when transaction confirmation is rejected', async () => {
promptHandler.mockResolvedValueOnce({
type: TriggerResponseTypes.SendTransactionConfirmationResponse,
data: { accepted: false },
});

await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler))
.rejects
.toThrow(PromptRejectedError);

expect(promptHandler).toHaveBeenCalledTimes(1);
});

it('should throw PromptRejectedError when PIN confirmation is rejected', async () => {
promptHandler
.mockResolvedValueOnce({
type: TriggerResponseTypes.SendTransactionConfirmationResponse,
data: { accepted: true },
})
.mockResolvedValueOnce({
type: TriggerResponseTypes.PinRequestResponse,
data: { accepted: false },
});

await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler))
.rejects
.toThrow(PromptRejectedError);

expect(promptHandler).toHaveBeenCalledTimes(2);
});

it('should throw InsufficientFundsError when not enough funds available', async () => {
wallet.sendManyOutputsSendTransaction.mockResolvedValue({
prepareTxData: jest.fn().mockRejectedValue(
new Error('Insufficient amount of tokens')
),
});

await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler))
.rejects
.toThrow(InsufficientFundsError);

expect(promptHandler).not.toHaveBeenCalled();
});

it('should throw SendTransactionError when transaction preparation fails', async () => {
wallet.sendManyOutputsSendTransaction.mockResolvedValue({
prepareTxData: jest.fn().mockRejectedValue(
new Error('Failed to prepare transaction')
),
});

await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler))
.rejects
.toThrow(SendTransactionError);

expect(promptHandler).not.toHaveBeenCalled();
});

it('should throw SendTransactionError when transaction execution fails', async () => {
promptHandler
.mockResolvedValueOnce({
type: TriggerResponseTypes.SendTransactionConfirmationResponse,
data: { accepted: true },
})
.mockResolvedValueOnce({
type: TriggerResponseTypes.PinRequestResponse,
data: { accepted: true, pinCode: '1234' },
});

sendTransactionMock.mockRejectedValue(
new Error('Failed to execute transaction')
);

await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler))
.rejects
.toThrow(SendTransactionError);

expect(promptHandler).toHaveBeenCalledTimes(3); // Confirmation, PIN, and Loading
});

it('should throw DifferentNetworkError when networks do not match', async () => {
wallet.getNetwork.mockReturnValue('mainnet');

await expect(sendTransaction(rpcRequest, wallet, {}, promptHandler))
.rejects
.toThrow(DifferentNetworkError);

expect(promptHandler).not.toHaveBeenCalled();
});
});
5 changes: 3 additions & 2 deletions packages/hathor-rpc-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"typescript-eslint": "7.13.0"
},
"dependencies": {
"@hathor/wallet-lib": "1.11.0",
"zod": "^3.24.1"
"@hathor/wallet-lib": "2.0.1",
"long": "5.2.3",
"zod": "3.24.1"
}
}
14 changes: 14 additions & 0 deletions packages/hathor-rpc-handler/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export class NoUtxosAvailableError extends Error {};

export class SignMessageError extends Error {};

export class InsufficientFundsError extends Error {
constructor(message: string) {
super(message);
this.name = 'InsufficientFundsError';
}
}

export class InvalidParamsError extends Error {
constructor(message: string) {
super(message);
Expand All @@ -41,3 +48,10 @@ export class InvalidParamTypeError extends Error {
this.name = 'InvalidParamTypeError';
}
}

export class SendTransactionError extends Error {
constructor(message: string) {
super(message);
this.name = 'SendTransactionError';
}
}
10 changes: 9 additions & 1 deletion packages/hathor-rpc-handler/src/rpcHandler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
RpcResponse,
CreateTokenRpcRequest,
SignOracleDataRpcRequest,
SendTransactionRpcRequest,
} from '../types';
import {
getAddress,
Expand All @@ -29,9 +30,10 @@ import {
getConnectedNetwork,
signOracleData,
signWithAddress,
createToken,
sendTransaction,
} from '../rpcMethods';
import { InvalidRpcMethod } from '../errors';
import { createToken } from '../rpcMethods/createToken';

export const handleRpcRequest = async (
request: RpcRequest,
Expand Down Expand Up @@ -88,6 +90,12 @@ export const handleRpcRequest = async (
requestMetadata,
promptHandler,
);
case RpcMethods.SendTransaction: return sendTransaction(
request as SendTransactionRpcRequest,
wallet,
requestMetadata,
promptHandler,
);
default: throw new InvalidRpcMethod();
}
};
1 change: 1 addition & 0 deletions packages/hathor-rpc-handler/src/rpcMethods/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './signWithAddress';
export * from './getConnectedNetwork';
export * from './signOracleData';
export * from './createToken';
export * from './sendTransaction';
Loading
Loading