kaspa-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +148 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +184 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/integration.test.d.ts +1 -0
- package/dist/integration.test.js +159 -0
- package/dist/kaspa/api.d.ts +11 -0
- package/dist/kaspa/api.js +49 -0
- package/dist/kaspa/api.test.d.ts +1 -0
- package/dist/kaspa/api.test.js +177 -0
- package/dist/kaspa/setup.d.ts +1 -0
- package/dist/kaspa/setup.js +4 -0
- package/dist/kaspa/transaction.d.ts +5 -0
- package/dist/kaspa/transaction.js +60 -0
- package/dist/kaspa/transaction.test.d.ts +1 -0
- package/dist/kaspa/transaction.test.js +170 -0
- package/dist/kaspa/wallet.d.ts +15 -0
- package/dist/kaspa/wallet.js +97 -0
- package/dist/kaspa/wallet.test.d.ts +1 -0
- package/dist/kaspa/wallet.test.js +160 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +4 -0
- package/dist/tools/get-balance.d.ts +9 -0
- package/dist/tools/get-balance.js +21 -0
- package/dist/tools/get-balance.test.d.ts +1 -0
- package/dist/tools/get-balance.test.js +93 -0
- package/dist/tools/get-fee-estimate.d.ts +6 -0
- package/dist/tools/get-fee-estimate.js +13 -0
- package/dist/tools/get-fee-estimate.test.d.ts +1 -0
- package/dist/tools/get-fee-estimate.test.js +74 -0
- package/dist/tools/get-my-address.d.ts +4 -0
- package/dist/tools/get-my-address.js +9 -0
- package/dist/tools/get-my-address.test.d.ts +1 -0
- package/dist/tools/get-my-address.test.js +32 -0
- package/dist/tools/get-transaction.d.ts +21 -0
- package/dist/tools/get-transaction.js +36 -0
- package/dist/tools/get-transaction.test.d.ts +1 -0
- package/dist/tools/get-transaction.test.js +119 -0
- package/dist/tools/send-kaspa.d.ts +10 -0
- package/dist/tools/send-kaspa.js +53 -0
- package/dist/tools/send-kaspa.test.d.ts +1 -0
- package/dist/tools/send-kaspa.test.js +147 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.js +3 -0
- package/package.json +39 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ABOUTME: Unit tests for KaspaApi REST client
|
|
2
|
+
// ABOUTME: Tests API methods with mocked fetch responses
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { KaspaApi, getApi } from './api.js';
|
|
5
|
+
describe('KaspaApi', () => {
|
|
6
|
+
const mockFetch = vi.fn();
|
|
7
|
+
const originalFetch = globalThis.fetch;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
globalThis.fetch = mockFetch;
|
|
10
|
+
mockFetch.mockReset();
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = originalFetch;
|
|
14
|
+
});
|
|
15
|
+
describe('constructor', () => {
|
|
16
|
+
it('uses mainnet endpoint by default', () => {
|
|
17
|
+
const api = new KaspaApi();
|
|
18
|
+
expect(api).toBeInstanceOf(KaspaApi);
|
|
19
|
+
});
|
|
20
|
+
it('uses testnet-10 endpoint when specified', () => {
|
|
21
|
+
const api = new KaspaApi('testnet-10');
|
|
22
|
+
expect(api).toBeInstanceOf(KaspaApi);
|
|
23
|
+
});
|
|
24
|
+
it('uses testnet-11 endpoint when specified', () => {
|
|
25
|
+
const api = new KaspaApi('testnet-11');
|
|
26
|
+
expect(api).toBeInstanceOf(KaspaApi);
|
|
27
|
+
});
|
|
28
|
+
it('falls back to mainnet for unknown network', () => {
|
|
29
|
+
const api = new KaspaApi('unknown-network');
|
|
30
|
+
expect(api).toBeInstanceOf(KaspaApi);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('getBalance', () => {
|
|
34
|
+
it('fetches balance for an address', async () => {
|
|
35
|
+
const mockResponse = { address: 'kaspa:test', balance: '1000000000' };
|
|
36
|
+
mockFetch.mockResolvedValueOnce({
|
|
37
|
+
ok: true,
|
|
38
|
+
json: () => Promise.resolve(mockResponse),
|
|
39
|
+
});
|
|
40
|
+
const api = new KaspaApi();
|
|
41
|
+
const result = await api.getBalance('kaspa:test');
|
|
42
|
+
expect(result).toEqual(mockResponse);
|
|
43
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.kaspa.org/addresses/kaspa:test/balance', expect.objectContaining({
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
it('throws error on API failure', async () => {
|
|
48
|
+
mockFetch.mockResolvedValueOnce({
|
|
49
|
+
ok: false,
|
|
50
|
+
status: 404,
|
|
51
|
+
text: () => Promise.resolve('Not found'),
|
|
52
|
+
});
|
|
53
|
+
const api = new KaspaApi();
|
|
54
|
+
await expect(api.getBalance('kaspa:invalid')).rejects.toThrow('API error 404: Not found');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('getUtxos', () => {
|
|
58
|
+
it('fetches UTXOs for an address', async () => {
|
|
59
|
+
const mockResponse = [
|
|
60
|
+
{
|
|
61
|
+
address: 'kaspa:test',
|
|
62
|
+
outpoint: { transactionId: 'abc123', index: 0 },
|
|
63
|
+
utxoEntry: {
|
|
64
|
+
amount: '1000000000',
|
|
65
|
+
scriptPublicKey: { scriptPublicKey: '20abc' },
|
|
66
|
+
blockDaaScore: '12345',
|
|
67
|
+
isCoinbase: false,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
mockFetch.mockResolvedValueOnce({
|
|
72
|
+
ok: true,
|
|
73
|
+
json: () => Promise.resolve(mockResponse),
|
|
74
|
+
});
|
|
75
|
+
const api = new KaspaApi();
|
|
76
|
+
const result = await api.getUtxos('kaspa:test');
|
|
77
|
+
expect(result).toEqual(mockResponse);
|
|
78
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.kaspa.org/addresses/kaspa:test/utxos', expect.any(Object));
|
|
79
|
+
});
|
|
80
|
+
it('throws error on API failure', async () => {
|
|
81
|
+
mockFetch.mockResolvedValueOnce({
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 500,
|
|
84
|
+
text: () => Promise.resolve('Server error'),
|
|
85
|
+
});
|
|
86
|
+
const api = new KaspaApi();
|
|
87
|
+
await expect(api.getUtxos('kaspa:test')).rejects.toThrow('API error 500');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('getFeeEstimate', () => {
|
|
91
|
+
it('fetches fee estimates', async () => {
|
|
92
|
+
const mockResponse = {
|
|
93
|
+
priorityBucket: { feerate: 1.5, estimatedSeconds: 10 },
|
|
94
|
+
normalBuckets: [{ feerate: 1.0, estimatedSeconds: 30 }],
|
|
95
|
+
lowBuckets: [{ feerate: 0.5, estimatedSeconds: 60 }],
|
|
96
|
+
};
|
|
97
|
+
mockFetch.mockResolvedValueOnce({
|
|
98
|
+
ok: true,
|
|
99
|
+
json: () => Promise.resolve(mockResponse),
|
|
100
|
+
});
|
|
101
|
+
const api = new KaspaApi();
|
|
102
|
+
const result = await api.getFeeEstimate();
|
|
103
|
+
expect(result).toEqual(mockResponse);
|
|
104
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.kaspa.org/info/fee-estimate', expect.any(Object));
|
|
105
|
+
});
|
|
106
|
+
it('throws error on API failure', async () => {
|
|
107
|
+
mockFetch.mockResolvedValueOnce({
|
|
108
|
+
ok: false,
|
|
109
|
+
status: 503,
|
|
110
|
+
text: () => Promise.resolve('Service unavailable'),
|
|
111
|
+
});
|
|
112
|
+
const api = new KaspaApi();
|
|
113
|
+
await expect(api.getFeeEstimate()).rejects.toThrow('API error 503');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('getTransaction', () => {
|
|
117
|
+
it('fetches transaction details', async () => {
|
|
118
|
+
const mockResponse = {
|
|
119
|
+
transaction_id: 'abc123',
|
|
120
|
+
block_hash: ['block1'],
|
|
121
|
+
block_time: 1234567890,
|
|
122
|
+
is_accepted: true,
|
|
123
|
+
inputs: [],
|
|
124
|
+
outputs: [],
|
|
125
|
+
};
|
|
126
|
+
mockFetch.mockResolvedValueOnce({
|
|
127
|
+
ok: true,
|
|
128
|
+
json: () => Promise.resolve(mockResponse),
|
|
129
|
+
});
|
|
130
|
+
const api = new KaspaApi();
|
|
131
|
+
const result = await api.getTransaction('abc123');
|
|
132
|
+
expect(result).toEqual(mockResponse);
|
|
133
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.kaspa.org/transactions/abc123', expect.any(Object));
|
|
134
|
+
});
|
|
135
|
+
it('throws error on API failure', async () => {
|
|
136
|
+
mockFetch.mockResolvedValueOnce({
|
|
137
|
+
ok: false,
|
|
138
|
+
status: 404,
|
|
139
|
+
text: () => Promise.resolve('Not found'),
|
|
140
|
+
});
|
|
141
|
+
const api = new KaspaApi();
|
|
142
|
+
await expect(api.getTransaction('notfound')).rejects.toThrow('API error 404');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('uses correct endpoints for different networks', () => {
|
|
146
|
+
it('uses testnet-10 endpoint', async () => {
|
|
147
|
+
mockFetch.mockResolvedValueOnce({
|
|
148
|
+
ok: true,
|
|
149
|
+
json: () => Promise.resolve({ priorityBucket: { feerate: 1 }, normalBuckets: [], lowBuckets: [] }),
|
|
150
|
+
});
|
|
151
|
+
const api = new KaspaApi('testnet-10');
|
|
152
|
+
await api.getFeeEstimate();
|
|
153
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api-tn10.kaspa.org/info/fee-estimate', expect.any(Object));
|
|
154
|
+
});
|
|
155
|
+
it('uses testnet-11 endpoint', async () => {
|
|
156
|
+
mockFetch.mockResolvedValueOnce({
|
|
157
|
+
ok: true,
|
|
158
|
+
json: () => Promise.resolve({ priorityBucket: { feerate: 1 }, normalBuckets: [], lowBuckets: [] }),
|
|
159
|
+
});
|
|
160
|
+
const api = new KaspaApi('testnet-11');
|
|
161
|
+
await api.getFeeEstimate();
|
|
162
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api-tn11.kaspa.org/info/fee-estimate', expect.any(Object));
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('getApi', () => {
|
|
167
|
+
it('returns cached instance for same network', () => {
|
|
168
|
+
const api1 = getApi('mainnet');
|
|
169
|
+
const api2 = getApi('mainnet');
|
|
170
|
+
expect(api1).toBe(api2);
|
|
171
|
+
});
|
|
172
|
+
it('returns new instance for different network', () => {
|
|
173
|
+
const api1 = getApi('mainnet');
|
|
174
|
+
const api2 = getApi('testnet-10');
|
|
175
|
+
expect(api1).not.toBe(api2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ABOUTME: Transaction building and submission module
|
|
2
|
+
// ABOUTME: Uses kaspa-wasm Generator for KIP-9 compliant transaction creation
|
|
3
|
+
import * as kaspa from 'kaspa-wasm';
|
|
4
|
+
import { getApi } from './api.js';
|
|
5
|
+
import { getWallet } from './wallet.js';
|
|
6
|
+
const { Generator, RpcClient, Resolver, Encoding, sompiToKaspaString, Address, } = kaspa;
|
|
7
|
+
export async function sendKaspa(to, amountSompi, priorityFee = 0n) {
|
|
8
|
+
const wallet = getWallet();
|
|
9
|
+
const senderAddress = wallet.getAddress();
|
|
10
|
+
const api = getApi(wallet.getNetworkId());
|
|
11
|
+
const rpc = new RpcClient({
|
|
12
|
+
resolver: new Resolver(),
|
|
13
|
+
encoding: Encoding.Borsh,
|
|
14
|
+
networkId: wallet.getNetworkId(),
|
|
15
|
+
});
|
|
16
|
+
await rpc.connect({});
|
|
17
|
+
try {
|
|
18
|
+
const { isSynced } = await rpc.getServerInfo();
|
|
19
|
+
if (!isSynced) {
|
|
20
|
+
throw new Error('RPC node is not synced');
|
|
21
|
+
}
|
|
22
|
+
// Fetch UTXOs via RPC - returns properly formatted UtxoEntry objects
|
|
23
|
+
const { entries } = await rpc.getUtxosByAddresses([new Address(senderAddress)]);
|
|
24
|
+
if (!entries || entries.length === 0) {
|
|
25
|
+
throw new Error('No UTXOs available');
|
|
26
|
+
}
|
|
27
|
+
// Check balance
|
|
28
|
+
const feeEstimate = await api.getFeeEstimate();
|
|
29
|
+
const totalBalance = entries.reduce((sum, e) => sum + e.amount, 0n);
|
|
30
|
+
const estimatedFee = BigInt(Math.ceil(feeEstimate.priorityBucket.feerate * 3000));
|
|
31
|
+
const totalRequired = amountSompi + estimatedFee + priorityFee;
|
|
32
|
+
if (totalBalance < totalRequired) {
|
|
33
|
+
throw new Error(`Insufficient balance: have ${sompiToKaspaString(totalBalance)} KAS, need ~${sompiToKaspaString(totalRequired)} KAS (including estimated fees)`);
|
|
34
|
+
}
|
|
35
|
+
// Sort UTXOs by amount (smallest first for efficient UTXO consolidation)
|
|
36
|
+
entries.sort((a, b) => (a.amount > b.amount ? 1 : -1));
|
|
37
|
+
// Create generator with RPC-provided UTXOs
|
|
38
|
+
const generator = new Generator({
|
|
39
|
+
entries,
|
|
40
|
+
outputs: [{ address: to, amount: amountSompi }],
|
|
41
|
+
priorityFee,
|
|
42
|
+
changeAddress: senderAddress,
|
|
43
|
+
networkId: wallet.getNetworkId(),
|
|
44
|
+
});
|
|
45
|
+
let pending;
|
|
46
|
+
let lastTxId = '';
|
|
47
|
+
while ((pending = await generator.next())) {
|
|
48
|
+
await pending.sign([wallet.getPrivateKey()]);
|
|
49
|
+
lastTxId = await pending.submit(rpc);
|
|
50
|
+
}
|
|
51
|
+
const summary = generator.summary();
|
|
52
|
+
return {
|
|
53
|
+
txId: lastTxId,
|
|
54
|
+
fee: sompiToKaspaString(summary.fees).toString(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await rpc.disconnect();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// ABOUTME: Unit tests for transaction module
|
|
2
|
+
// ABOUTME: Tests transaction building, signing, and submission
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
const mockRpcClient = {
|
|
5
|
+
connect: vi.fn(),
|
|
6
|
+
disconnect: vi.fn(),
|
|
7
|
+
getServerInfo: vi.fn(),
|
|
8
|
+
getUtxosByAddresses: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
const mockPendingTransaction = {
|
|
11
|
+
sign: vi.fn(),
|
|
12
|
+
submit: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
const mockGenerator = {
|
|
15
|
+
next: vi.fn(),
|
|
16
|
+
summary: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
vi.mock('kaspa-wasm', () => {
|
|
19
|
+
class MockAddress {
|
|
20
|
+
address;
|
|
21
|
+
constructor(addr) {
|
|
22
|
+
this.address = addr;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class MockResolver {
|
|
26
|
+
}
|
|
27
|
+
class MockRpcClient {
|
|
28
|
+
constructor() {
|
|
29
|
+
return mockRpcClient;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class MockGenerator {
|
|
33
|
+
constructor() {
|
|
34
|
+
return mockGenerator;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
Address: MockAddress,
|
|
39
|
+
Resolver: MockResolver,
|
|
40
|
+
RpcClient: MockRpcClient,
|
|
41
|
+
Generator: MockGenerator,
|
|
42
|
+
Encoding: { Borsh: 'borsh' },
|
|
43
|
+
sompiToKaspaString: (sompi) => {
|
|
44
|
+
const kas = Number(sompi) / 100_000_000;
|
|
45
|
+
return kas.toString();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
vi.mock('./wallet.js', () => ({
|
|
50
|
+
getWallet: vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
vi.mock('./api.js', () => ({
|
|
53
|
+
getApi: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
import { sendKaspa } from './transaction.js';
|
|
56
|
+
import { getWallet } from './wallet.js';
|
|
57
|
+
import { getApi } from './api.js';
|
|
58
|
+
describe('sendKaspa', () => {
|
|
59
|
+
const mockWallet = {
|
|
60
|
+
getAddress: vi.fn(),
|
|
61
|
+
getNetworkId: vi.fn(),
|
|
62
|
+
getPrivateKey: vi.fn(),
|
|
63
|
+
};
|
|
64
|
+
const mockApi = {
|
|
65
|
+
getFeeEstimate: vi.fn(),
|
|
66
|
+
};
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.mocked(getWallet).mockReturnValue(mockWallet);
|
|
69
|
+
vi.mocked(getApi).mockReturnValue(mockApi);
|
|
70
|
+
mockWallet.getAddress.mockReturnValue('kaspa:qpwallet123');
|
|
71
|
+
mockWallet.getNetworkId.mockReturnValue('testnet-10');
|
|
72
|
+
mockWallet.getPrivateKey.mockReturnValue({ key: 'privatekey' });
|
|
73
|
+
mockApi.getFeeEstimate.mockResolvedValue({
|
|
74
|
+
priorityBucket: { feerate: 1.0 },
|
|
75
|
+
});
|
|
76
|
+
mockRpcClient.connect.mockResolvedValue(undefined);
|
|
77
|
+
mockRpcClient.disconnect.mockResolvedValue(undefined);
|
|
78
|
+
mockRpcClient.getServerInfo.mockResolvedValue({ isSynced: true });
|
|
79
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({
|
|
80
|
+
entries: [{ amount: 1000000000n }],
|
|
81
|
+
});
|
|
82
|
+
mockPendingTransaction.sign.mockResolvedValue(undefined);
|
|
83
|
+
mockPendingTransaction.submit.mockResolvedValue('txid123');
|
|
84
|
+
mockGenerator.next
|
|
85
|
+
.mockResolvedValueOnce(mockPendingTransaction)
|
|
86
|
+
.mockResolvedValueOnce(undefined);
|
|
87
|
+
mockGenerator.summary.mockReturnValue({ fees: 1000n });
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
it('sends transaction successfully', async () => {
|
|
93
|
+
const result = await sendKaspa('kaspa:qprecipient', 100000000n, 0n);
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
txId: 'txid123',
|
|
96
|
+
fee: '0.00001',
|
|
97
|
+
});
|
|
98
|
+
expect(mockRpcClient.connect).toHaveBeenCalledWith({});
|
|
99
|
+
expect(mockRpcClient.getServerInfo).toHaveBeenCalled();
|
|
100
|
+
expect(mockRpcClient.disconnect).toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
it('throws error when RPC is not synced', async () => {
|
|
103
|
+
mockRpcClient.getServerInfo.mockResolvedValue({ isSynced: false });
|
|
104
|
+
await expect(sendKaspa('kaspa:qprecipient', 100000000n)).rejects.toThrow('RPC node is not synced');
|
|
105
|
+
expect(mockRpcClient.disconnect).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
it('throws error when no UTXOs available', async () => {
|
|
108
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({ entries: [] });
|
|
109
|
+
await expect(sendKaspa('kaspa:qprecipient', 100000000n)).rejects.toThrow('No UTXOs available');
|
|
110
|
+
});
|
|
111
|
+
it('throws error when entries is undefined', async () => {
|
|
112
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({ entries: undefined });
|
|
113
|
+
await expect(sendKaspa('kaspa:qprecipient', 100000000n)).rejects.toThrow('No UTXOs available');
|
|
114
|
+
});
|
|
115
|
+
it('throws error for insufficient balance', async () => {
|
|
116
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({
|
|
117
|
+
entries: [{ amount: 1000n }],
|
|
118
|
+
});
|
|
119
|
+
await expect(sendKaspa('kaspa:qprecipient', 100000000n)).rejects.toThrow(/Insufficient balance/);
|
|
120
|
+
});
|
|
121
|
+
it('uses priority fee when provided', async () => {
|
|
122
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({
|
|
123
|
+
entries: [{ amount: 2000000000n }],
|
|
124
|
+
});
|
|
125
|
+
await sendKaspa('kaspa:qprecipient', 100000000n, 1000n);
|
|
126
|
+
expect(mockPendingTransaction.sign).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
it('disconnects RPC even on error', async () => {
|
|
129
|
+
mockRpcClient.getServerInfo.mockRejectedValue(new Error('Connection failed'));
|
|
130
|
+
await expect(sendKaspa('kaspa:qprecipient', 100000000n)).rejects.toThrow('Connection failed');
|
|
131
|
+
expect(mockRpcClient.disconnect).toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
it('handles multiple pending transactions', async () => {
|
|
134
|
+
const mockPending1 = { sign: vi.fn(), submit: vi.fn().mockResolvedValue('tx1') };
|
|
135
|
+
const mockPending2 = { sign: vi.fn(), submit: vi.fn().mockResolvedValue('tx2') };
|
|
136
|
+
mockGenerator.next
|
|
137
|
+
.mockReset()
|
|
138
|
+
.mockResolvedValueOnce(mockPending1)
|
|
139
|
+
.mockResolvedValueOnce(mockPending2)
|
|
140
|
+
.mockResolvedValueOnce(undefined);
|
|
141
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({
|
|
142
|
+
entries: [{ amount: 5000000000n }],
|
|
143
|
+
});
|
|
144
|
+
const result = await sendKaspa('kaspa:qprecipient', 100000000n);
|
|
145
|
+
expect(result.txId).toBe('tx2');
|
|
146
|
+
expect(mockPending1.sign).toHaveBeenCalled();
|
|
147
|
+
expect(mockPending2.sign).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
it('sorts UTXOs by amount (smallest first)', async () => {
|
|
150
|
+
const entries = [
|
|
151
|
+
{ amount: 3000000000n },
|
|
152
|
+
{ amount: 1000000000n },
|
|
153
|
+
{ amount: 2000000000n },
|
|
154
|
+
];
|
|
155
|
+
mockRpcClient.getUtxosByAddresses.mockResolvedValue({ entries });
|
|
156
|
+
await sendKaspa('kaspa:qprecipient', 100000000n);
|
|
157
|
+
expect(entries[0].amount).toBe(1000000000n);
|
|
158
|
+
expect(entries[1].amount).toBe(2000000000n);
|
|
159
|
+
expect(entries[2].amount).toBe(3000000000n);
|
|
160
|
+
});
|
|
161
|
+
it('uses correct API for network', async () => {
|
|
162
|
+
mockWallet.getNetworkId.mockReturnValue('mainnet');
|
|
163
|
+
await sendKaspa('kaspa:qprecipient', 100000000n);
|
|
164
|
+
expect(getApi).toHaveBeenCalledWith('mainnet');
|
|
165
|
+
});
|
|
166
|
+
it('defaults priority fee to 0', async () => {
|
|
167
|
+
const result = await sendKaspa('kaspa:qprecipient', 100000000n);
|
|
168
|
+
expect(result).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as kaspa from 'kaspa-wasm';
|
|
2
|
+
export type NetworkTypeName = 'mainnet' | 'testnet-10' | 'testnet-11';
|
|
3
|
+
export declare class KaspaWallet {
|
|
4
|
+
private privateKey;
|
|
5
|
+
private keypair;
|
|
6
|
+
private network;
|
|
7
|
+
private constructor();
|
|
8
|
+
static fromPrivateKey(privateKeyHex: string, network?: NetworkTypeName): KaspaWallet;
|
|
9
|
+
static fromMnemonic(phrase: string, network?: NetworkTypeName, accountIndex?: number): KaspaWallet;
|
|
10
|
+
getAddress(): string;
|
|
11
|
+
getPrivateKey(): kaspa.PrivateKey;
|
|
12
|
+
getNetworkType(): kaspa.NetworkType;
|
|
13
|
+
getNetworkId(): string;
|
|
14
|
+
}
|
|
15
|
+
export declare function getWallet(): KaspaWallet;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ABOUTME: Wallet module for key management and address derivation
|
|
2
|
+
// ABOUTME: Supports both mnemonic (BIP39) and raw private key
|
|
3
|
+
import * as kaspa from 'kaspa-wasm';
|
|
4
|
+
const { PrivateKey, NetworkType, Mnemonic, XPrv, PrivateKeyGenerator } = kaspa;
|
|
5
|
+
function getNetworkType(network) {
|
|
6
|
+
switch (network) {
|
|
7
|
+
case 'mainnet':
|
|
8
|
+
return NetworkType.Mainnet;
|
|
9
|
+
case 'testnet-10':
|
|
10
|
+
case 'testnet-11':
|
|
11
|
+
return NetworkType.Testnet;
|
|
12
|
+
/* c8 ignore next 2 */
|
|
13
|
+
default:
|
|
14
|
+
return NetworkType.Mainnet;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function derivePrivateKeyFromMnemonic(phrase, accountIndex = 0) {
|
|
18
|
+
const mnemonic = new Mnemonic(phrase);
|
|
19
|
+
const seed = mnemonic.toSeed();
|
|
20
|
+
const xprv = new XPrv(seed);
|
|
21
|
+
// BIP44 path: m/44'/111111'/account'
|
|
22
|
+
// 44' = purpose (BIP44)
|
|
23
|
+
// 111111' = Kaspa coin type
|
|
24
|
+
const derived = xprv
|
|
25
|
+
.deriveChild(44, true)
|
|
26
|
+
.deriveChild(111111, true)
|
|
27
|
+
.deriveChild(accountIndex, true);
|
|
28
|
+
const xprvString = derived.intoString('xprv');
|
|
29
|
+
const privateKeyGenerator = new PrivateKeyGenerator(xprvString, false, BigInt(accountIndex));
|
|
30
|
+
return privateKeyGenerator.receiveKey(0);
|
|
31
|
+
}
|
|
32
|
+
export class KaspaWallet {
|
|
33
|
+
privateKey;
|
|
34
|
+
keypair;
|
|
35
|
+
network;
|
|
36
|
+
constructor(privateKey, network) {
|
|
37
|
+
this.privateKey = privateKey;
|
|
38
|
+
this.keypair = privateKey.toKeypair();
|
|
39
|
+
this.network = network;
|
|
40
|
+
}
|
|
41
|
+
static fromPrivateKey(privateKeyHex, network = 'mainnet') {
|
|
42
|
+
if (!privateKeyHex) {
|
|
43
|
+
throw new Error('Private key is required');
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const privateKey = new PrivateKey(privateKeyHex);
|
|
47
|
+
return new KaspaWallet(privateKey, network);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error('Invalid private key format');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
static fromMnemonic(phrase, network = 'mainnet', accountIndex = 0) {
|
|
54
|
+
if (!phrase) {
|
|
55
|
+
throw new Error('Mnemonic phrase is required');
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const privateKey = derivePrivateKeyFromMnemonic(phrase, accountIndex);
|
|
59
|
+
return new KaspaWallet(privateKey, network);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error('Invalid mnemonic phrase');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
getAddress() {
|
|
66
|
+
const networkType = getNetworkType(this.network);
|
|
67
|
+
return this.keypair.toAddress(networkType).toString();
|
|
68
|
+
}
|
|
69
|
+
getPrivateKey() {
|
|
70
|
+
return this.privateKey;
|
|
71
|
+
}
|
|
72
|
+
getNetworkType() {
|
|
73
|
+
return getNetworkType(this.network);
|
|
74
|
+
}
|
|
75
|
+
getNetworkId() {
|
|
76
|
+
return this.network;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let walletInstance = null;
|
|
80
|
+
export function getWallet() {
|
|
81
|
+
if (!walletInstance) {
|
|
82
|
+
const mnemonic = process.env.KASPA_MNEMONIC;
|
|
83
|
+
const privateKey = process.env.KASPA_PRIVATE_KEY;
|
|
84
|
+
const network = process.env.KASPA_NETWORK || 'mainnet';
|
|
85
|
+
const accountIndex = parseInt(process.env.KASPA_ACCOUNT_INDEX || '0', 10);
|
|
86
|
+
if (mnemonic) {
|
|
87
|
+
walletInstance = KaspaWallet.fromMnemonic(mnemonic, network, accountIndex);
|
|
88
|
+
}
|
|
89
|
+
else if (privateKey) {
|
|
90
|
+
walletInstance = KaspaWallet.fromPrivateKey(privateKey, network);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
throw new Error('Either KASPA_MNEMONIC or KASPA_PRIVATE_KEY environment variable must be set');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return walletInstance;
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|