kasia-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/README.md +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +64 -0
- package/dist/kasia/alias.d.ts +5 -0
- package/dist/kasia/alias.js +46 -0
- package/dist/kasia/alias.test.d.ts +1 -0
- package/dist/kasia/alias.test.js +27 -0
- package/dist/kasia/crypto.d.ts +3 -0
- package/dist/kasia/crypto.js +127 -0
- package/dist/kasia/crypto.test.d.ts +1 -0
- package/dist/kasia/crypto.test.js +93 -0
- package/dist/kasia/indexer.d.ts +13 -0
- package/dist/kasia/indexer.js +63 -0
- package/dist/kasia/indexer.test.d.ts +1 -0
- package/dist/kasia/indexer.test.js +58 -0
- package/dist/kasia/protocol.d.ts +5 -0
- package/dist/kasia/protocol.js +24 -0
- package/dist/kasia/protocol.test.d.ts +1 -0
- package/dist/kasia/protocol.test.js +32 -0
- package/dist/tools/accept-handshake.d.ts +5 -0
- package/dist/tools/accept-handshake.js +29 -0
- package/dist/tools/accept-handshake.test.d.ts +1 -0
- package/dist/tools/accept-handshake.test.js +30 -0
- package/dist/tools/get-conversations.d.ts +2 -0
- package/dist/tools/get-conversations.js +80 -0
- package/dist/tools/get-conversations.test.d.ts +1 -0
- package/dist/tools/get-conversations.test.js +114 -0
- package/dist/tools/get-messages.d.ts +5 -0
- package/dist/tools/get-messages.js +61 -0
- package/dist/tools/get-messages.test.d.ts +1 -0
- package/dist/tools/get-messages.test.js +78 -0
- package/dist/tools/get-requests.d.ts +8 -0
- package/dist/tools/get-requests.js +49 -0
- package/dist/tools/get-requests.test.d.ts +1 -0
- package/dist/tools/get-requests.test.js +79 -0
- package/dist/tools/read-self-stash.d.ts +11 -0
- package/dist/tools/read-self-stash.js +31 -0
- package/dist/tools/read-self-stash.test.d.ts +1 -0
- package/dist/tools/read-self-stash.test.js +66 -0
- package/dist/tools/send-handshake.d.ts +5 -0
- package/dist/tools/send-handshake.js +27 -0
- package/dist/tools/send-handshake.test.d.ts +1 -0
- package/dist/tools/send-handshake.test.js +40 -0
- package/dist/tools/send-message.d.ts +6 -0
- package/dist/tools/send-message.js +23 -0
- package/dist/tools/send-message.test.d.ts +1 -0
- package/dist/tools/send-message.test.js +33 -0
- package/dist/tools/send-payment.d.ts +7 -0
- package/dist/tools/send-payment.js +23 -0
- package/dist/tools/send-payment.test.d.ts +1 -0
- package/dist/tools/send-payment.test.js +38 -0
- package/dist/tools/write-self-stash.d.ts +6 -0
- package/dist/tools/write-self-stash.js +19 -0
- package/dist/tools/write-self-stash.test.d.ts +1 -0
- package/dist/tools/write-self-stash.test.js +36 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +4 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# kasia-mcp
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ABOUTME: MCP server entry point for Kasia encrypted messaging
|
|
3
|
+
// ABOUTME: Registers all tools and starts the stdio transport
|
|
4
|
+
import 'kaspa-mcp/kaspa/setup';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { getWallet } from 'kaspa-mcp/kaspa/wallet';
|
|
9
|
+
import { wrapToolHandler } from 'kaspa-mcp/wrap-tool-handler';
|
|
10
|
+
import { sendHandshake } from './tools/send-handshake.js';
|
|
11
|
+
import { acceptHandshake } from './tools/accept-handshake.js';
|
|
12
|
+
import { sendMessage } from './tools/send-message.js';
|
|
13
|
+
import { writeSelfStash } from './tools/write-self-stash.js';
|
|
14
|
+
import { getConversations } from './tools/get-conversations.js';
|
|
15
|
+
import { getRequests } from './tools/get-requests.js';
|
|
16
|
+
import { getMessages } from './tools/get-messages.js';
|
|
17
|
+
import { readSelfStash } from './tools/read-self-stash.js';
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: 'kasia-mcp',
|
|
20
|
+
version: '0.1.0',
|
|
21
|
+
}, {
|
|
22
|
+
capabilities: {
|
|
23
|
+
tools: {},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
// Write tools — return KasiaWriteResult with payload for send_kaspa delegation
|
|
27
|
+
server.tool('kasia_send_handshake', 'Start a Kasia encrypted conversation with a Kaspa address. Returns a transaction payload — use send_kaspa to broadcast it.', {
|
|
28
|
+
address: z.string().describe('Recipient Kaspa address to start a conversation with'),
|
|
29
|
+
}, async (params) => wrapToolHandler(() => sendHandshake({ address: params.address })));
|
|
30
|
+
server.tool('kasia_accept_handshake', 'Accept an incoming Kasia handshake request. Returns a transaction payload — use send_kaspa to broadcast it.', {
|
|
31
|
+
address: z.string().describe('Address of the person who sent the handshake'),
|
|
32
|
+
}, async (params) => wrapToolHandler(() => acceptHandshake({ address: params.address })));
|
|
33
|
+
server.tool('kasia_send_message', 'Send an encrypted message in a Kasia conversation. Returns a transaction payload — use send_kaspa to broadcast it.', {
|
|
34
|
+
address: z.string().describe('Contact Kaspa address to send the message to'),
|
|
35
|
+
message: z.string().describe('Message text to encrypt and send'),
|
|
36
|
+
}, async (params) => wrapToolHandler(() => sendMessage({ address: params.address, message: params.message })));
|
|
37
|
+
server.tool('kasia_write_self_stash', 'Store encrypted data on-chain for yourself. Returns a transaction payload — use send_kaspa to broadcast it.', {
|
|
38
|
+
data: z.string().describe('Data to encrypt and store'),
|
|
39
|
+
scope: z.string().describe('Scope/category for the stash entry (e.g. "saved_handshake", "notes")'),
|
|
40
|
+
}, async (params) => wrapToolHandler(() => writeSelfStash({ data: params.data, scope: params.scope })));
|
|
41
|
+
// Read tools — query indexer and decrypt
|
|
42
|
+
server.tool('kasia_get_conversations', 'List all Kasia conversations with their status (pending_outgoing, pending_incoming, active).', async () => wrapToolHandler(() => getConversations()));
|
|
43
|
+
server.tool('kasia_get_requests', 'List pending incoming handshake requests that need to be accepted.', async () => wrapToolHandler(() => getRequests()));
|
|
44
|
+
server.tool('kasia_get_messages', 'Read decrypted messages in a Kasia conversation, sorted by time.', {
|
|
45
|
+
address: z.string().describe('Contact Kaspa address to read messages from'),
|
|
46
|
+
}, async (params) => wrapToolHandler(() => getMessages({ address: params.address })));
|
|
47
|
+
server.tool('kasia_read_self_stash', 'Read your encrypted self-stash entries by scope.', {
|
|
48
|
+
scope: z.string().describe('Scope/category to read (e.g. "saved_handshake", "notes")'),
|
|
49
|
+
}, async (params) => wrapToolHandler(() => readSelfStash({ scope: params.scope })));
|
|
50
|
+
async function main() {
|
|
51
|
+
const wallet = getWallet();
|
|
52
|
+
if (wallet.getNetworkId() !== 'mainnet') {
|
|
53
|
+
throw new Error('Kasia messaging is only available on mainnet');
|
|
54
|
+
}
|
|
55
|
+
const transport = new StdioServerTransport();
|
|
56
|
+
await server.connect(transport);
|
|
57
|
+
console.error('Kasia MCP server started');
|
|
58
|
+
}
|
|
59
|
+
main().catch((error) => {
|
|
60
|
+
const safeMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
61
|
+
console.error('Fatal error:', safeMessage);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
64
|
+
export { server };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ABOUTME: Deterministic alias derivation for Kasia conversations
|
|
2
|
+
// ABOUTME: ECDH + HKDF with asymmetric context pubkeys (68-byte info: "chat" || shared_secret || context_pubkey)
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import { extractXOnlyPubkeyFromAddress } from './crypto.js';
|
|
5
|
+
const ALIAS_BYTE_LENGTH = 6;
|
|
6
|
+
export async function deriveAliases(myPrivateKeyHex, theirAddress) {
|
|
7
|
+
const ecdh = crypto.createECDH('secp256k1');
|
|
8
|
+
ecdh.setPrivateKey(Buffer.from(myPrivateKeyHex, 'hex'));
|
|
9
|
+
const myXOnlyPubkey = ecdh.getPublicKey(null, 'compressed').subarray(1); // strip prefix
|
|
10
|
+
const theirXOnlyPubkey = extractXOnlyPubkeyFromAddress(theirAddress);
|
|
11
|
+
const theirCompressed = Buffer.concat([Buffer.from([0x02]), theirXOnlyPubkey]);
|
|
12
|
+
const sharedSecret = ecdh.computeSecret(theirCompressed);
|
|
13
|
+
const myAlias = deriveAlias(sharedSecret, myXOnlyPubkey);
|
|
14
|
+
const theirAlias = deriveAlias(sharedSecret, theirXOnlyPubkey);
|
|
15
|
+
return { myAlias, theirAlias };
|
|
16
|
+
}
|
|
17
|
+
function deriveAlias(sharedSecret, contextPubkey) {
|
|
18
|
+
// HKDF-Extract: PRK = HMAC-SHA256(salt=empty, ikm=shared_secret)
|
|
19
|
+
const prk = crypto.createHmac('sha256', Buffer.alloc(0))
|
|
20
|
+
.update(sharedSecret)
|
|
21
|
+
.digest();
|
|
22
|
+
// Construct info: "chat" (4) || shared_secret (32) || context_pubkey (32) = 68 bytes
|
|
23
|
+
const info = Buffer.alloc(68);
|
|
24
|
+
info.set(Buffer.from('chat', 'utf-8'), 0);
|
|
25
|
+
info.set(sharedSecret, 4);
|
|
26
|
+
info.set(contextPubkey, 36);
|
|
27
|
+
// HKDF-Expand: output 6 bytes
|
|
28
|
+
const aliasBytes = hkdfExpand(prk, info, ALIAS_BYTE_LENGTH);
|
|
29
|
+
return aliasBytes.toString('hex');
|
|
30
|
+
}
|
|
31
|
+
function hkdfExpand(prk, info, length) {
|
|
32
|
+
// RFC 5869 HKDF-Expand
|
|
33
|
+
const hashLen = 32; // SHA-256
|
|
34
|
+
const n = Math.ceil(length / hashLen);
|
|
35
|
+
const output = Buffer.alloc(n * hashLen);
|
|
36
|
+
let prev = Buffer.alloc(0);
|
|
37
|
+
for (let i = 1; i <= n; i++) {
|
|
38
|
+
const hmac = crypto.createHmac('sha256', prk);
|
|
39
|
+
hmac.update(prev);
|
|
40
|
+
hmac.update(info);
|
|
41
|
+
hmac.update(Buffer.from([i]));
|
|
42
|
+
prev = Buffer.from(hmac.digest());
|
|
43
|
+
prev.copy(output, (i - 1) * hashLen);
|
|
44
|
+
}
|
|
45
|
+
return output.subarray(0, length);
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ABOUTME: Tests for deterministic alias derivation
|
|
2
|
+
// ABOUTME: Verifies 12-char hex format, asymmetric outputs, and determinism
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { deriveAliases } from './alias.js';
|
|
5
|
+
describe('alias', () => {
|
|
6
|
+
// Two keypairs for testing asymmetric property
|
|
7
|
+
const alicePrivateKey = 'e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35';
|
|
8
|
+
// Bob's address must be different from Alice's to produce distinct aliases
|
|
9
|
+
const bobAddress = 'kaspa:qq258y97v6uc26jqhztw49v9xh6wa85fc8sd5zqxz9jmhkky4pnvvgc3z08eq';
|
|
10
|
+
it('derives 12-character hex aliases', async () => {
|
|
11
|
+
const { myAlias, theirAlias } = await deriveAliases(alicePrivateKey, bobAddress);
|
|
12
|
+
expect(myAlias).toHaveLength(12);
|
|
13
|
+
expect(theirAlias).toHaveLength(12);
|
|
14
|
+
expect(myAlias).toMatch(/^[0-9a-f]{12}$/);
|
|
15
|
+
expect(theirAlias).toMatch(/^[0-9a-f]{12}$/);
|
|
16
|
+
});
|
|
17
|
+
it('produces different myAlias and theirAlias', async () => {
|
|
18
|
+
const { myAlias, theirAlias } = await deriveAliases(alicePrivateKey, bobAddress);
|
|
19
|
+
expect(myAlias).not.toBe(theirAlias);
|
|
20
|
+
});
|
|
21
|
+
it('is deterministic', async () => {
|
|
22
|
+
const result1 = await deriveAliases(alicePrivateKey, bobAddress);
|
|
23
|
+
const result2 = await deriveAliases(alicePrivateKey, bobAddress);
|
|
24
|
+
expect(result1.myAlias).toBe(result2.myAlias);
|
|
25
|
+
expect(result1.theirAlias).toBe(result2.theirAlias);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ABOUTME: Kasia message encryption and decryption using Node.js crypto
|
|
2
|
+
// ABOUTME: ECDH on secp256k1, HKDF-SHA256, ChaCha20-Poly1305 AEAD
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
const NONCE_LENGTH = 12;
|
|
5
|
+
const COMPRESSED_PUBKEY_LENGTH = 33;
|
|
6
|
+
const X_ONLY_PUBKEY_LENGTH = 32;
|
|
7
|
+
const MAC_LENGTH = 16;
|
|
8
|
+
const HKDF_KEY_LENGTH = 32;
|
|
9
|
+
const BECH32_CHECKSUM_LENGTH = 8;
|
|
10
|
+
// Kaspa bech32 character set for address encoding
|
|
11
|
+
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
12
|
+
function convertBits(data, fromBits, toBits) {
|
|
13
|
+
let acc = 0;
|
|
14
|
+
let bits = 0;
|
|
15
|
+
const result = [];
|
|
16
|
+
const maxv = (1 << toBits) - 1;
|
|
17
|
+
for (const value of data) {
|
|
18
|
+
/* v8 ignore next -- @preserve */
|
|
19
|
+
if (value < 0 || (value >> fromBits) !== 0)
|
|
20
|
+
return null;
|
|
21
|
+
acc = (acc << fromBits) | value;
|
|
22
|
+
bits += fromBits;
|
|
23
|
+
while (bits >= toBits) {
|
|
24
|
+
bits -= toBits;
|
|
25
|
+
result.push((acc >> bits) & maxv);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
export function extractXOnlyPubkeyFromAddress(address) {
|
|
34
|
+
const colonIndex = address.indexOf(':');
|
|
35
|
+
if (colonIndex === -1) {
|
|
36
|
+
throw new Error('Invalid Kaspa address: missing prefix separator');
|
|
37
|
+
}
|
|
38
|
+
const payload = address.substring(colonIndex + 1);
|
|
39
|
+
if (payload.length < BECH32_CHECKSUM_LENGTH + 2) {
|
|
40
|
+
throw new Error('Invalid Kaspa address: payload too short');
|
|
41
|
+
}
|
|
42
|
+
const data5bit = [];
|
|
43
|
+
for (const c of payload) {
|
|
44
|
+
const idx = BECH32_CHARSET.indexOf(c);
|
|
45
|
+
if (idx === -1) {
|
|
46
|
+
throw new Error(`Invalid Kaspa address: invalid character '${c}'`);
|
|
47
|
+
}
|
|
48
|
+
data5bit.push(idx);
|
|
49
|
+
}
|
|
50
|
+
// Strip the 8-character bech32 checksum
|
|
51
|
+
const dataWithoutChecksum = data5bit.slice(0, -BECH32_CHECKSUM_LENGTH);
|
|
52
|
+
const bytes = convertBits(dataWithoutChecksum, 5, 8);
|
|
53
|
+
if (!bytes || bytes.length < 2) {
|
|
54
|
+
throw new Error('Invalid Kaspa address: failed to decode payload');
|
|
55
|
+
}
|
|
56
|
+
// First byte is the version (0 = PubKey), rest is the x-only pubkey
|
|
57
|
+
const pubkeyBytes = bytes.slice(1);
|
|
58
|
+
if (pubkeyBytes.length !== X_ONLY_PUBKEY_LENGTH) {
|
|
59
|
+
throw new Error(`Invalid Kaspa address: expected ${X_ONLY_PUBKEY_LENGTH} byte pubkey, got ${pubkeyBytes.length}`);
|
|
60
|
+
}
|
|
61
|
+
return Buffer.from(pubkeyBytes);
|
|
62
|
+
}
|
|
63
|
+
export async function encrypt(plaintext, recipientAddress) {
|
|
64
|
+
const xOnlyPubkey = extractXOnlyPubkeyFromAddress(recipientAddress);
|
|
65
|
+
const recipientCompressed = Buffer.concat([Buffer.from([0x02]), xOnlyPubkey]);
|
|
66
|
+
const ephemeral = crypto.createECDH('secp256k1');
|
|
67
|
+
ephemeral.generateKeys();
|
|
68
|
+
const ephemeralPubkey = ephemeral.getPublicKey(null, 'compressed');
|
|
69
|
+
const sharedSecret = ephemeral.computeSecret(recipientCompressed);
|
|
70
|
+
const key = crypto.hkdfSync('sha256', sharedSecret, Buffer.alloc(0), Buffer.alloc(0), HKDF_KEY_LENGTH);
|
|
71
|
+
const nonce = crypto.randomBytes(NONCE_LENGTH);
|
|
72
|
+
const cipher = crypto.createCipheriv('chacha20-poly1305', Buffer.from(key), nonce, { authTagLength: MAC_LENGTH });
|
|
73
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
|
74
|
+
const mac = cipher.getAuthTag();
|
|
75
|
+
return Buffer.concat([nonce, ephemeralPubkey, ciphertext, mac]);
|
|
76
|
+
}
|
|
77
|
+
export async function decrypt(encrypted, privateKeyHex) {
|
|
78
|
+
const minLength = NONCE_LENGTH + X_ONLY_PUBKEY_LENGTH + 1 + MAC_LENGTH;
|
|
79
|
+
if (encrypted.length < minLength) {
|
|
80
|
+
throw new Error(`Encrypted payload too short: ${encrypted.length} bytes (minimum ${minLength})`);
|
|
81
|
+
}
|
|
82
|
+
const nonce = encrypted.subarray(0, NONCE_LENGTH);
|
|
83
|
+
const possiblePrefix = encrypted[NONCE_LENGTH];
|
|
84
|
+
const isCompressed = possiblePrefix === 0x02 || possiblePrefix === 0x03;
|
|
85
|
+
let ephemeralPubkey;
|
|
86
|
+
let ciphertextAndMac;
|
|
87
|
+
if (isCompressed && encrypted.length >= NONCE_LENGTH + COMPRESSED_PUBKEY_LENGTH + 1 + MAC_LENGTH) {
|
|
88
|
+
ephemeralPubkey = Buffer.from(encrypted.subarray(NONCE_LENGTH, NONCE_LENGTH + COMPRESSED_PUBKEY_LENGTH));
|
|
89
|
+
ciphertextAndMac = encrypted.subarray(NONCE_LENGTH + COMPRESSED_PUBKEY_LENGTH);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
ephemeralPubkey = Buffer.concat([
|
|
93
|
+
Buffer.from([0x02]),
|
|
94
|
+
encrypted.subarray(NONCE_LENGTH, NONCE_LENGTH + X_ONLY_PUBKEY_LENGTH),
|
|
95
|
+
]);
|
|
96
|
+
ciphertextAndMac = encrypted.subarray(NONCE_LENGTH + X_ONLY_PUBKEY_LENGTH);
|
|
97
|
+
}
|
|
98
|
+
const ciphertext = ciphertextAndMac.subarray(0, ciphertextAndMac.length - MAC_LENGTH);
|
|
99
|
+
const mac = ciphertextAndMac.subarray(ciphertextAndMac.length - MAC_LENGTH);
|
|
100
|
+
try {
|
|
101
|
+
return decryptWithPubkey(nonce, ephemeralPubkey, ciphertext, mac, privateKeyHex);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
// ECDH shared secrets are identical regardless of y-parity in secp256k1 (Node.js
|
|
105
|
+
// computeSecret returns the x-coordinate only), so this fallback is a defensive
|
|
106
|
+
// guard that cannot be reached in practice with standard ECDH implementations.
|
|
107
|
+
/* v8 ignore next 6 -- @preserve */
|
|
108
|
+
if (!isCompressed) {
|
|
109
|
+
const oddPubkey = Buffer.concat([
|
|
110
|
+
Buffer.from([0x03]),
|
|
111
|
+
encrypted.subarray(NONCE_LENGTH, NONCE_LENGTH + X_ONLY_PUBKEY_LENGTH),
|
|
112
|
+
]);
|
|
113
|
+
return decryptWithPubkey(nonce, oddPubkey, ciphertext, mac, privateKeyHex);
|
|
114
|
+
}
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function decryptWithPubkey(nonce, ephemeralPubkey, ciphertext, mac, privateKeyHex) {
|
|
119
|
+
const ecdh = crypto.createECDH('secp256k1');
|
|
120
|
+
ecdh.setPrivateKey(Buffer.from(privateKeyHex, 'hex'));
|
|
121
|
+
const sharedSecret = ecdh.computeSecret(ephemeralPubkey);
|
|
122
|
+
const key = crypto.hkdfSync('sha256', sharedSecret, Buffer.alloc(0), Buffer.alloc(0), HKDF_KEY_LENGTH);
|
|
123
|
+
const decipher = crypto.createDecipheriv('chacha20-poly1305', Buffer.from(key), nonce, { authTagLength: MAC_LENGTH });
|
|
124
|
+
decipher.setAuthTag(mac);
|
|
125
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
126
|
+
return decrypted.toString('utf-8');
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ABOUTME: Tests for Kasia encryption/decryption using Node.js crypto
|
|
2
|
+
// ABOUTME: Verifies round-trip encrypt/decrypt, ephemeral key uniqueness, parity fallback
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { encrypt, decrypt, extractXOnlyPubkeyFromAddress } from './crypto.js';
|
|
5
|
+
describe('crypto', () => {
|
|
6
|
+
// Use a known secp256k1 keypair for deterministic testing
|
|
7
|
+
const privateKeyHex = 'e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35';
|
|
8
|
+
// Address derived from the above private key on mainnet
|
|
9
|
+
const address = 'kaspa:qqu6xcqnxq2e0kh0g8a7tyaq9nz3859425n7ct03q58zarl5njzuy4wlaj98w';
|
|
10
|
+
describe('extractXOnlyPubkeyFromAddress', () => {
|
|
11
|
+
it('extracts the correct x-only pubkey from a mainnet address', () => {
|
|
12
|
+
const pubkey = extractXOnlyPubkeyFromAddress(address);
|
|
13
|
+
expect(pubkey.toString('hex')).toBe('39a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2');
|
|
14
|
+
expect(pubkey.length).toBe(32);
|
|
15
|
+
});
|
|
16
|
+
it('extracts the correct x-only pubkey from a testnet address', () => {
|
|
17
|
+
const testnetAddress = 'kaspatest:qqd6e65yefepe9wk0m9vuxdufxd80sphy67gwwd0vdaumzdt4tc9ssxd5s7gn';
|
|
18
|
+
const pubkey = extractXOnlyPubkeyFromAddress(testnetAddress);
|
|
19
|
+
expect(pubkey.toString('hex')).toBe('1bacea84ca721c95d67ecace19bc499a77c03726bc8739af637bcd89abaaf058');
|
|
20
|
+
});
|
|
21
|
+
it('throws on address without prefix separator', () => {
|
|
22
|
+
expect(() => extractXOnlyPubkeyFromAddress('noprefix')).toThrow('missing prefix separator');
|
|
23
|
+
});
|
|
24
|
+
it('throws on address with payload too short', () => {
|
|
25
|
+
expect(() => extractXOnlyPubkeyFromAddress('kaspa:q')).toThrow('payload too short');
|
|
26
|
+
});
|
|
27
|
+
it('throws on address with invalid bech32 characters', () => {
|
|
28
|
+
expect(() => extractXOnlyPubkeyFromAddress('kaspa:qINVALID' + 'q'.repeat(60))).toThrow('invalid character');
|
|
29
|
+
});
|
|
30
|
+
it('throws on address with non-zero trailing bits in bech32 data', () => {
|
|
31
|
+
// Changing the last data character from 'y' (0b01100) to 'p' (0b00001) creates
|
|
32
|
+
// a non-zero trailing bit that makes the 5-to-8-bit conversion fail
|
|
33
|
+
const badAddr = 'kaspa:qqu6xcqnxq2e0kh0g8a7tyaq9nz3859425n7ct03q58zarl5njzup4wlaj98w';
|
|
34
|
+
expect(() => extractXOnlyPubkeyFromAddress(badAddr)).toThrow('failed to decode');
|
|
35
|
+
});
|
|
36
|
+
it('throws on address with wrong pubkey length', () => {
|
|
37
|
+
// A shorter payload that decodes to 29 bytes instead of 32
|
|
38
|
+
const shortAddr = 'kaspa:' + 'q'.repeat(56);
|
|
39
|
+
expect(() => extractXOnlyPubkeyFromAddress(shortAddr)).toThrow('expected 32 byte pubkey');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('encrypt', () => {
|
|
43
|
+
it('encrypts and decrypts a message round-trip', async () => {
|
|
44
|
+
const plaintext = 'Hello Kasia!';
|
|
45
|
+
const encrypted = await encrypt(plaintext, address);
|
|
46
|
+
expect(encrypted).toBeInstanceOf(Buffer);
|
|
47
|
+
// nonce(12) + pubkey(33) + ciphertext(>=1) + mac(16) >= 62
|
|
48
|
+
expect(encrypted.length).toBeGreaterThanOrEqual(62);
|
|
49
|
+
const decrypted = await decrypt(encrypted, privateKeyHex);
|
|
50
|
+
expect(decrypted).toBe(plaintext);
|
|
51
|
+
});
|
|
52
|
+
it('produces different ciphertext each time (ephemeral keys)', async () => {
|
|
53
|
+
const plaintext = 'determinism test';
|
|
54
|
+
const enc1 = await encrypt(plaintext, address);
|
|
55
|
+
const enc2 = await encrypt(plaintext, address);
|
|
56
|
+
expect(enc1).not.toEqual(enc2);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('decrypt', () => {
|
|
60
|
+
it('decrypts with parity fallback for x-only pubkeys', async () => {
|
|
61
|
+
// Build a payload with 32-byte x-only ephemeral pubkey (strip prefix)
|
|
62
|
+
const plaintext = 'parity test';
|
|
63
|
+
const fullEncrypted = await encrypt(plaintext, address);
|
|
64
|
+
// Strip the 0x02/0x03 prefix byte from ephemeral pubkey
|
|
65
|
+
const nonce = fullEncrypted.subarray(0, 12);
|
|
66
|
+
const pubkey33 = fullEncrypted.subarray(12, 45);
|
|
67
|
+
const ciphertextAndMac = fullEncrypted.subarray(45);
|
|
68
|
+
const xOnlyPubkey = pubkey33.subarray(1); // 32 bytes, strip prefix
|
|
69
|
+
const xOnlyPayload = Buffer.concat([nonce, xOnlyPubkey, ciphertextAndMac]);
|
|
70
|
+
const decrypted = await decrypt(xOnlyPayload, privateKeyHex);
|
|
71
|
+
expect(decrypted).toBe(plaintext);
|
|
72
|
+
});
|
|
73
|
+
it('throws on encrypted payload that is too short', async () => {
|
|
74
|
+
await expect(decrypt(Buffer.from('too short'), privateKeyHex)).rejects.toThrow('Encrypted payload too short');
|
|
75
|
+
});
|
|
76
|
+
it('decrypts payload with compressed ephemeral pubkey (0x02 prefix)', async () => {
|
|
77
|
+
const plaintext = 'compressed even';
|
|
78
|
+
const encrypted = await encrypt(plaintext, address);
|
|
79
|
+
// encrypt() always produces compressed pubkeys, so this is the normal path
|
|
80
|
+
expect(encrypted[12]).toBeGreaterThanOrEqual(0x02);
|
|
81
|
+
expect(encrypted[12]).toBeLessThanOrEqual(0x03);
|
|
82
|
+
const decrypted = await decrypt(encrypted, privateKeyHex);
|
|
83
|
+
expect(decrypted).toBe(plaintext);
|
|
84
|
+
});
|
|
85
|
+
it('throws when compressed pubkey decryption fails with wrong key', async () => {
|
|
86
|
+
const plaintext = 'wrong key test';
|
|
87
|
+
const encrypted = await encrypt(plaintext, address);
|
|
88
|
+
// Use a different private key that won't produce the correct shared secret
|
|
89
|
+
const wrongKeyHex = '0000000000000000000000000000000000000000000000000000000000000001';
|
|
90
|
+
await expect(decrypt(encrypted, wrongKeyHex)).rejects.toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HandshakeResponse, ContextualMessageResponse, PaymentResponse, SelfStashResponse } from '../types.js';
|
|
2
|
+
export declare class KasiaIndexer {
|
|
3
|
+
private baseUrl;
|
|
4
|
+
constructor(baseUrl: string);
|
|
5
|
+
getHandshakesBySender(address: string, limit?: number, blockTime?: number): Promise<HandshakeResponse[]>;
|
|
6
|
+
getHandshakesByReceiver(address: string, limit?: number, blockTime?: number): Promise<HandshakeResponse[]>;
|
|
7
|
+
getMessagesBySender(address: string, alias: string, limit?: number, blockTime?: number): Promise<ContextualMessageResponse[]>;
|
|
8
|
+
getPaymentsBySender(address: string, limit?: number, blockTime?: number): Promise<PaymentResponse[]>;
|
|
9
|
+
getPaymentsByReceiver(address: string, limit?: number, blockTime?: number): Promise<PaymentResponse[]>;
|
|
10
|
+
getSelfStashByOwner(address: string, scope: string, limit?: number, blockTime?: number): Promise<SelfStashResponse[]>;
|
|
11
|
+
private fetch;
|
|
12
|
+
}
|
|
13
|
+
export declare function getIndexer(): KasiaIndexer;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ABOUTME: HTTP client for the Kasia indexer API
|
|
2
|
+
// ABOUTME: Handles all read operations against the messaging indexer at indexer.kasia.fyi
|
|
3
|
+
const DEFAULT_INDEXER_URL = 'https://indexer.kasia.fyi';
|
|
4
|
+
const DEFAULT_LIMIT = 50;
|
|
5
|
+
function hexEncodeString(str) {
|
|
6
|
+
return Buffer.from(str, 'utf-8').toString('hex');
|
|
7
|
+
}
|
|
8
|
+
export class KasiaIndexer {
|
|
9
|
+
baseUrl;
|
|
10
|
+
constructor(baseUrl) {
|
|
11
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
12
|
+
}
|
|
13
|
+
async getHandshakesBySender(address, limit = DEFAULT_LIMIT, blockTime = 0) {
|
|
14
|
+
return this.fetch('/handshakes/by-sender', { address, limit: String(limit), block_time: String(blockTime) });
|
|
15
|
+
}
|
|
16
|
+
async getHandshakesByReceiver(address, limit = DEFAULT_LIMIT, blockTime = 0) {
|
|
17
|
+
return this.fetch('/handshakes/by-receiver', { address, limit: String(limit), block_time: String(blockTime) });
|
|
18
|
+
}
|
|
19
|
+
async getMessagesBySender(address, alias, limit = DEFAULT_LIMIT, blockTime = 0) {
|
|
20
|
+
return this.fetch('/contextual-messages/by-sender', {
|
|
21
|
+
address,
|
|
22
|
+
alias: hexEncodeString(alias),
|
|
23
|
+
limit: String(limit),
|
|
24
|
+
block_time: String(blockTime),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async getPaymentsBySender(address, limit = DEFAULT_LIMIT, blockTime = 0) {
|
|
28
|
+
return this.fetch('/payments/by-sender', { address, limit: String(limit), block_time: String(blockTime) });
|
|
29
|
+
}
|
|
30
|
+
async getPaymentsByReceiver(address, limit = DEFAULT_LIMIT, blockTime = 0) {
|
|
31
|
+
return this.fetch('/payments/by-receiver', { address, limit: String(limit), block_time: String(blockTime) });
|
|
32
|
+
}
|
|
33
|
+
async getSelfStashByOwner(address, scope, limit = DEFAULT_LIMIT, blockTime = 0) {
|
|
34
|
+
return this.fetch('/self-stash/by-owner', {
|
|
35
|
+
owner: address,
|
|
36
|
+
scope: hexEncodeString(scope),
|
|
37
|
+
limit: String(limit),
|
|
38
|
+
block_time: String(blockTime),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async fetch(path, params) {
|
|
42
|
+
const url = new URL(path, this.baseUrl);
|
|
43
|
+
for (const [key, value] of Object.entries(params)) {
|
|
44
|
+
url.searchParams.set(key, value);
|
|
45
|
+
}
|
|
46
|
+
const response = await globalThis.fetch(url.toString(), {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
headers: { 'Accept': 'application/json' },
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Indexer API error: ${response.status} ${response.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
return response.json();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let indexerInstance = null;
|
|
57
|
+
export function getIndexer() {
|
|
58
|
+
if (!indexerInstance) {
|
|
59
|
+
const url = process.env.KASIA_INDEXER_URL || DEFAULT_INDEXER_URL;
|
|
60
|
+
indexerInstance = new KasiaIndexer(url);
|
|
61
|
+
}
|
|
62
|
+
return indexerInstance;
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ABOUTME: Tests for the Kasia indexer HTTP client
|
|
2
|
+
// ABOUTME: Verifies URL construction, alias hex-encoding, and error handling
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
import { KasiaIndexer, getIndexer } from './indexer.js';
|
|
5
|
+
// Mock global fetch
|
|
6
|
+
const mockFetch = vi.fn();
|
|
7
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
8
|
+
describe('KasiaIndexer', () => {
|
|
9
|
+
const indexer = new KasiaIndexer('https://indexer.kasia.fyi');
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mockFetch.mockReset();
|
|
12
|
+
});
|
|
13
|
+
it('fetches handshakes by sender', async () => {
|
|
14
|
+
mockFetch.mockResolvedValueOnce({
|
|
15
|
+
ok: true,
|
|
16
|
+
json: async () => [{ tx_id: 'abc', sender: 'kaspa:q1', receiver: 'kaspa:q2', block_time: 123, accepting_block: null, accepting_daa_score: null, message_payload: 'deadbeef' }],
|
|
17
|
+
});
|
|
18
|
+
const result = await indexer.getHandshakesBySender('kaspa:q1');
|
|
19
|
+
expect(result).toHaveLength(1);
|
|
20
|
+
expect(result[0].tx_id).toBe('abc');
|
|
21
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/handshakes/by-sender?address=kaspa%3Aq1'), expect.anything());
|
|
22
|
+
});
|
|
23
|
+
it('fetches handshakes by receiver', async () => {
|
|
24
|
+
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
|
25
|
+
await indexer.getHandshakesByReceiver('kaspa:q2');
|
|
26
|
+
const calledUrl = mockFetch.mock.calls[0][0];
|
|
27
|
+
expect(calledUrl).toContain('/handshakes/by-receiver?address=kaspa%3Aq2');
|
|
28
|
+
});
|
|
29
|
+
it('hex-encodes alias for contextual messages', async () => {
|
|
30
|
+
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
|
31
|
+
await indexer.getMessagesBySender('kaspa:q1', 'a1b2c3d4e5f6');
|
|
32
|
+
const calledUrl = mockFetch.mock.calls[0][0];
|
|
33
|
+
const aliasHex = Buffer.from('a1b2c3d4e5f6', 'utf-8').toString('hex');
|
|
34
|
+
expect(calledUrl).toContain(`alias=${aliasHex}`);
|
|
35
|
+
});
|
|
36
|
+
it('hex-encodes scope for self-stash', async () => {
|
|
37
|
+
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
|
38
|
+
await indexer.getSelfStashByOwner('kaspa:q1', 'saved_handshake');
|
|
39
|
+
const calledUrl = mockFetch.mock.calls[0][0];
|
|
40
|
+
const scopeHex = Buffer.from('saved_handshake', 'utf-8').toString('hex');
|
|
41
|
+
expect(calledUrl).toContain(`scope=${scopeHex}`);
|
|
42
|
+
});
|
|
43
|
+
it('throws on HTTP error', async () => {
|
|
44
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error' });
|
|
45
|
+
await expect(indexer.getHandshakesBySender('kaspa:q1')).rejects.toThrow('500');
|
|
46
|
+
});
|
|
47
|
+
it('strips trailing slash from base URL', () => {
|
|
48
|
+
const idx = new KasiaIndexer('https://indexer.kasia.fyi/');
|
|
49
|
+
expect(idx.baseUrl).toBe('https://indexer.kasia.fyi');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('getIndexer', () => {
|
|
53
|
+
it('returns a KasiaIndexer singleton', () => {
|
|
54
|
+
const a = getIndexer();
|
|
55
|
+
const b = getIndexer();
|
|
56
|
+
expect(a).toBe(b);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function encodeHandshakePayload(encryptedHex: string): string;
|
|
2
|
+
export declare function encodeCommPayload(alias: string, encryptedBytes: Buffer): string;
|
|
3
|
+
export declare function encodePaymentPayload(encryptedBytes: Buffer): string;
|
|
4
|
+
export declare function encodeSelfStashPayload(scope: string, encryptedHex: string): string;
|
|
5
|
+
export declare function payloadToTransactionHex(payload: string): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ABOUTME: Kasia protocol message encoding for on-chain payloads
|
|
2
|
+
// ABOUTME: Two strategies: binary hex (handshake, self-stash) and UTF-8+base64 (comm, payment)
|
|
3
|
+
export function encodeHandshakePayload(encryptedHex) {
|
|
4
|
+
const prefixHex = Buffer.from('ciph_msg:1:handshake:', 'utf-8').toString('hex');
|
|
5
|
+
return prefixHex + encryptedHex;
|
|
6
|
+
}
|
|
7
|
+
export function encodeCommPayload(alias, encryptedBytes) {
|
|
8
|
+
return `ciph_msg:1:comm:${alias}:${encryptedBytes.toString('base64')}`;
|
|
9
|
+
}
|
|
10
|
+
export function encodePaymentPayload(encryptedBytes) {
|
|
11
|
+
return `ciph_msg:1:payment:${encryptedBytes.toString('base64')}`;
|
|
12
|
+
}
|
|
13
|
+
export function encodeSelfStashPayload(scope, encryptedHex) {
|
|
14
|
+
const prefixHex = Buffer.from(`ciph_msg:1:self_stash:${scope}:`, 'utf-8').toString('hex');
|
|
15
|
+
return prefixHex + encryptedHex;
|
|
16
|
+
}
|
|
17
|
+
export function payloadToTransactionHex(payload) {
|
|
18
|
+
// Detect encoding: if no colons and valid hex → already hex bytes
|
|
19
|
+
// Otherwise → UTF-8 encode the string to hex
|
|
20
|
+
if (!payload.includes(':') && /^[0-9a-f]+$/i.test(payload)) {
|
|
21
|
+
return payload;
|
|
22
|
+
}
|
|
23
|
+
return Buffer.from(payload, 'utf-8').toString('hex');
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ABOUTME: Tests for Kasia protocol message encoding
|
|
2
|
+
// ABOUTME: Verifies handshake (hex), comm (base64), payment (base64), self-stash (hex) encodings
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { encodeHandshakePayload, encodeCommPayload, encodePaymentPayload, encodeSelfStashPayload, } from './protocol.js';
|
|
5
|
+
describe('protocol', () => {
|
|
6
|
+
const encryptedHex = 'deadbeef0123456789abcdef';
|
|
7
|
+
it('encodes handshake as full hex', () => {
|
|
8
|
+
const payload = encodeHandshakePayload(encryptedHex);
|
|
9
|
+
// "ciph_msg:1:handshake:" in hex + encrypted hex
|
|
10
|
+
const prefixHex = Buffer.from('ciph_msg:1:handshake:', 'utf-8').toString('hex');
|
|
11
|
+
expect(payload).toBe(prefixHex + encryptedHex);
|
|
12
|
+
});
|
|
13
|
+
it('encodes comm as utf-8 string with base64', () => {
|
|
14
|
+
const encryptedBytes = Buffer.from(encryptedHex, 'hex');
|
|
15
|
+
const alias = 'a1b2c3d4e5f6';
|
|
16
|
+
const payload = encodeCommPayload(alias, encryptedBytes);
|
|
17
|
+
const expected = `ciph_msg:1:comm:${alias}:${encryptedBytes.toString('base64')}`;
|
|
18
|
+
expect(payload).toBe(expected);
|
|
19
|
+
});
|
|
20
|
+
it('encodes payment as utf-8 string with base64', () => {
|
|
21
|
+
const encryptedBytes = Buffer.from(encryptedHex, 'hex');
|
|
22
|
+
const payload = encodePaymentPayload(encryptedBytes);
|
|
23
|
+
const expected = `ciph_msg:1:payment:${encryptedBytes.toString('base64')}`;
|
|
24
|
+
expect(payload).toBe(expected);
|
|
25
|
+
});
|
|
26
|
+
it('encodes self-stash as full hex with scope', () => {
|
|
27
|
+
const scope = 'saved_handshake';
|
|
28
|
+
const payload = encodeSelfStashPayload(scope, encryptedHex);
|
|
29
|
+
const prefixHex = Buffer.from(`ciph_msg:1:self_stash:${scope}:`, 'utf-8').toString('hex');
|
|
30
|
+
expect(payload).toBe(prefixHex + encryptedHex);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ABOUTME: MCP tool to accept an incoming Kasia handshake request
|
|
2
|
+
// ABOUTME: Generates acceptance handshake payload for broadcasting via kaspa-mcp's send_kaspa
|
|
3
|
+
import { getWallet } from 'kaspa-mcp/kaspa/wallet';
|
|
4
|
+
import { encrypt } from '../kasia/crypto.js';
|
|
5
|
+
import { deriveAliases } from '../kasia/alias.js';
|
|
6
|
+
import { encodeHandshakePayload } from '../kasia/protocol.js';
|
|
7
|
+
import { KASIA_MIN_AMOUNT } from '../types.js';
|
|
8
|
+
export async function acceptHandshake(params) {
|
|
9
|
+
const wallet = getWallet();
|
|
10
|
+
const myPrivateKeyHex = wallet.getPrivateKey().toString();
|
|
11
|
+
const { myAlias, theirAlias } = await deriveAliases(myPrivateKeyHex, params.address);
|
|
12
|
+
const handshake = {
|
|
13
|
+
type: 'handshake',
|
|
14
|
+
alias: myAlias,
|
|
15
|
+
theirAlias,
|
|
16
|
+
timestamp: Date.now(),
|
|
17
|
+
version: 1,
|
|
18
|
+
isResponse: true,
|
|
19
|
+
};
|
|
20
|
+
const encrypted = await encrypt(JSON.stringify(handshake), params.address);
|
|
21
|
+
const payload = encodeHandshakePayload(encrypted.toString('hex'));
|
|
22
|
+
return {
|
|
23
|
+
action: 'accept_handshake',
|
|
24
|
+
to: params.address,
|
|
25
|
+
amount: KASIA_MIN_AMOUNT,
|
|
26
|
+
payload,
|
|
27
|
+
instructions: `Call send_kaspa with to="${params.address}", amount="${KASIA_MIN_AMOUNT}", payload="${payload}" to broadcast this handshake acceptance.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|