@novasamatech/handoff-service 0.6.18
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 +156 -0
- package/dist/codec.d.ts +8 -0
- package/dist/codec.js +9 -0
- package/dist/codec.spec.d.ts +1 -0
- package/dist/codec.spec.js +34 -0
- package/dist/crypto/encryption.d.ts +9 -0
- package/dist/crypto/encryption.js +22 -0
- package/dist/crypto/encryption.spec.d.ts +1 -0
- package/dist/crypto/encryption.spec.js +54 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/index.js +2 -0
- package/dist/crypto/ticket.d.ts +7 -0
- package/dist/crypto/ticket.js +28 -0
- package/dist/crypto/ticket.spec.d.ts +1 -0
- package/dist/crypto/ticket.spec.js +55 -0
- package/dist/fileLoader/fileLoader.d.ts +20 -0
- package/dist/fileLoader/fileLoader.js +96 -0
- package/dist/fileLoader/fileLoader.spec.d.ts +1 -0
- package/dist/fileLoader/fileLoader.spec.js +127 -0
- package/dist/fileLoader/index.d.ts +2 -0
- package/dist/fileLoader/index.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/rpc/client.d.ts +8 -0
- package/dist/rpc/client.js +29 -0
- package/dist/rpc/index.d.ts +3 -0
- package/dist/rpc/index.js +1 -0
- package/dist/rpc/scale.d.ts +32 -0
- package/dist/rpc/scale.js +23 -0
- package/dist/rpc/types.d.ts +16 -0
- package/dist/rpc/types.js +1 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @novasamatech/handoff-service
|
|
2
|
+
|
|
3
|
+
HOP (Handoff Pool) file transfer service for peer-to-peer chat. Uploads files to a Bulletin chain HOP pool as AES-256-GCM encrypted chunks and returns a compact identifier + claim ticket that the recipient uses to download and decrypt the file.
|
|
4
|
+
|
|
5
|
+
Non-custodial, ephemeral, end-to-end encrypted. The pool node only ever sees encrypted bytes. Wire format matches the iOS `HandoffService` and Android `HopFileUploader` implementations.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
npm install @novasamatech/handoff-service --save -E
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
A transfer happens in two roles:
|
|
16
|
+
|
|
17
|
+
- **Sender** generates a random 32-byte `ticket`. From the ticket the service derives an sr25519 keypair (signer) and an AES-256-GCM key (encryption) via keyed blake2b (`khash(ticket, "signer" | "encryption")`). The file is split into 2 MB chunks, each chunk is AES-GCM encrypted and submitted to the pool addressed to the ticket's public key. A SCALE-encoded metadata blob listing all chunk hashes is then encrypted and submitted the same way — its hash is the file `identifier`. The sender ships `{ identifier, claimTicket }` through a side channel (the chat message).
|
|
18
|
+
- **Recipient** re-derives the encryption key and signing keypair from `claimTicket`, signs the identifier to prove ownership, and calls `hop_claim` to fetch the encrypted metadata. The metadata's chunk hashes are claimed and decrypted one by one, then concatenated to reconstruct the original bytes.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
┌────────┐ hop_submit(enc(chunk_i), [pubkey]) ┌─────────┐
|
|
22
|
+
│ Sender │ ──────────────────────────────────────► │ HOP │
|
|
23
|
+
│ │ hop_submit(enc(metadata), [pubkey]) │ Pool │
|
|
24
|
+
└────────┘ ──────────────────────────────────────► └─────────┘
|
|
25
|
+
ticket ▲
|
|
26
|
+
│ │ hop_claim(hash, sig)
|
|
27
|
+
│ identifier + claimTicket (via chat) │
|
|
28
|
+
▼ ┌──────────┐
|
|
29
|
+
│ Recipient│
|
|
30
|
+
└──────────┘
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### Upload a file
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { createHopClient, uploadFile } from '@novasamatech/handoff-service';
|
|
39
|
+
|
|
40
|
+
// Your JSON-RPC transport — any function that calls methods on a HOP node.
|
|
41
|
+
const requestFn = <T>(method: string, params: unknown[]): Promise<T> =>
|
|
42
|
+
wsClient.request(method, params);
|
|
43
|
+
|
|
44
|
+
const hopClient = createHopClient(requestFn);
|
|
45
|
+
|
|
46
|
+
const result = await uploadFile({
|
|
47
|
+
data: fileBytes, // Uint8Array
|
|
48
|
+
hopClient,
|
|
49
|
+
onProgress: (sent, total) => console.info(`${sent}/${total} chunks`),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (result.isErr()) {
|
|
53
|
+
console.error('Upload failed:', result.error);
|
|
54
|
+
} else {
|
|
55
|
+
const { identifier, claimTicket } = result.value;
|
|
56
|
+
// Send identifier + claimTicket to the recipient (e.g. inside an encrypted chat message).
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Download a file
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { createHopClient, downloadFile } from '@novasamatech/handoff-service';
|
|
64
|
+
|
|
65
|
+
const hopClient = createHopClient(requestFn);
|
|
66
|
+
|
|
67
|
+
const result = await downloadFile({
|
|
68
|
+
identifier, // Uint8Array from the sender
|
|
69
|
+
claimTicket, // Uint8Array from the sender
|
|
70
|
+
hopClient,
|
|
71
|
+
onProgress: (received, total) => console.info(`${received}/${total} chunks`),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (result.isErr()) {
|
|
75
|
+
console.error('Download failed:', result.error);
|
|
76
|
+
} else {
|
|
77
|
+
const fileBytes = result.value; // Uint8Array — original file contents
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Inspect pool capacity
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const status = await hopClient.poolStatus();
|
|
85
|
+
// { entryCount, totalBytes, maxBytes }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `createHopClient(requestFn): HopClient`
|
|
91
|
+
|
|
92
|
+
Wraps a JSON-RPC request function into a typed HOP client. `requestFn` is called with the raw method names `hop_submit`, `hop_claim`, and `hop_poolStatus` — bring your own WebSocket / HTTP transport.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
type HopClient = {
|
|
96
|
+
submit(data: Uint8Array, recipients: Uint8Array[]): ResultAsync<PoolStatus, Error>;
|
|
97
|
+
claim(hash: Uint8Array, signature: Uint8Array): ResultAsync<Uint8Array, Error>;
|
|
98
|
+
poolStatus(): ResultAsync<PoolStatus, Error>;
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `uploadFile(params): ResultAsync<UploadResult, Error>`
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
type UploadParams = {
|
|
106
|
+
data: Uint8Array;
|
|
107
|
+
hopClient: HopClient;
|
|
108
|
+
chunkSize?: number; // default 2_000_000
|
|
109
|
+
onProgress?: (sent: number, total: number) => void;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type UploadResult = {
|
|
113
|
+
identifier: Uint8Array; // blake2b-256 hash of the encrypted metadata
|
|
114
|
+
claimTicket: Uint8Array; // 32-byte secret — share with recipient
|
|
115
|
+
};
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `downloadFile(params): ResultAsync<Uint8Array, Error>`
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
type DownloadParams = {
|
|
122
|
+
identifier: Uint8Array;
|
|
123
|
+
claimTicket: Uint8Array;
|
|
124
|
+
hopClient: HopClient;
|
|
125
|
+
onProgress?: (received: number, total: number) => void;
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Reassembled bytes are validated against the `totalSize` encoded in the metadata; a size mismatch produces an `Err`.
|
|
130
|
+
|
|
131
|
+
### Crypto primitives
|
|
132
|
+
|
|
133
|
+
Exposed for advanced use cases (e.g. signing custom pool entries):
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import {
|
|
137
|
+
generateTicket,
|
|
138
|
+
derivePublicKey,
|
|
139
|
+
deriveEncryptionKey,
|
|
140
|
+
deriveSigningKeypair,
|
|
141
|
+
signWithTicket,
|
|
142
|
+
createFileEncryption,
|
|
143
|
+
} from '@novasamatech/handoff-service';
|
|
144
|
+
|
|
145
|
+
const ticket = generateTicket(); // 32 random bytes
|
|
146
|
+
const pubkey = derivePublicKey(ticket); // sr25519 public key
|
|
147
|
+
const signature = signWithTicket(ticket, messageBytes);
|
|
148
|
+
|
|
149
|
+
const enc = createFileEncryption(deriveEncryptionKey(ticket));
|
|
150
|
+
const ciphertext = enc.encrypt(plainBytes); // nonce(12) || ciphertext || tag(16)
|
|
151
|
+
const plain = enc.decrypt(ciphertext);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Error handling
|
|
155
|
+
|
|
156
|
+
All async operations return `neverthrow` `ResultAsync`, so errors are values rather than thrown exceptions. Chain with `.andThen` / `.map` or unwrap via `.isErr()` / `.value` / `.error`.
|
package/dist/codec.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal metadata stored in the HOP pool that references all chunks of an uploaded file.
|
|
3
|
+
* This is not part of the chat message — it's only used by the file loader.
|
|
4
|
+
*/
|
|
5
|
+
export declare const UploadedFile: import("scale-ts").Codec<{
|
|
6
|
+
totalSize: bigint;
|
|
7
|
+
chunks: Uint8Array<ArrayBufferLike>[];
|
|
8
|
+
}>;
|
package/dist/codec.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Bytes, Struct, Vector, u64 } from 'scale-ts';
|
|
2
|
+
/**
|
|
3
|
+
* Internal metadata stored in the HOP pool that references all chunks of an uploaded file.
|
|
4
|
+
* This is not part of the chat message — it's only used by the file loader.
|
|
5
|
+
*/
|
|
6
|
+
export const UploadedFile = Struct({
|
|
7
|
+
totalSize: u64,
|
|
8
|
+
chunks: Vector(Bytes()),
|
|
9
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { UploadedFile } from './codec.js';
|
|
3
|
+
describe('UploadedFile codec', () => {
|
|
4
|
+
it('encodes and decodes round-trip', () => {
|
|
5
|
+
const hash1 = new Uint8Array(32).fill(0xaa);
|
|
6
|
+
const hash2 = new Uint8Array(32).fill(0xbb);
|
|
7
|
+
const original = {
|
|
8
|
+
totalSize: 4000000n,
|
|
9
|
+
chunks: [hash1, hash2],
|
|
10
|
+
};
|
|
11
|
+
const encoded = UploadedFile.enc(original);
|
|
12
|
+
const decoded = UploadedFile.dec(encoded);
|
|
13
|
+
expect(decoded.totalSize).toBe(4000000n);
|
|
14
|
+
expect(decoded.chunks).toHaveLength(2);
|
|
15
|
+
expect(decoded.chunks[0]).toEqual(hash1);
|
|
16
|
+
expect(decoded.chunks[1]).toEqual(hash2);
|
|
17
|
+
});
|
|
18
|
+
it('handles single chunk', () => {
|
|
19
|
+
const hash = new Uint8Array(32).fill(0xcc);
|
|
20
|
+
const original = { totalSize: 100n, chunks: [hash] };
|
|
21
|
+
const encoded = UploadedFile.enc(original);
|
|
22
|
+
const decoded = UploadedFile.dec(encoded);
|
|
23
|
+
expect(decoded.totalSize).toBe(100n);
|
|
24
|
+
expect(decoded.chunks).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
it('handles many chunks', () => {
|
|
27
|
+
const chunks = Array.from({ length: 50 }, (_, i) => new Uint8Array(32).fill(i));
|
|
28
|
+
const original = { totalSize: 100000000n, chunks };
|
|
29
|
+
const encoded = UploadedFile.enc(original);
|
|
30
|
+
const decoded = UploadedFile.dec(encoded);
|
|
31
|
+
expect(decoded.totalSize).toBe(100000000n);
|
|
32
|
+
expect(decoded.chunks).toHaveLength(50);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type FileEncryption = {
|
|
2
|
+
encrypt(data: Uint8Array): Uint8Array;
|
|
3
|
+
decrypt(data: Uint8Array): Uint8Array;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* AES-256-GCM encryption for file chunks and metadata.
|
|
7
|
+
* Format: nonce (12 bytes) || ciphertext || tag (16 bytes)
|
|
8
|
+
*/
|
|
9
|
+
export declare function createFileEncryption(key: Uint8Array): FileEncryption;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { gcm } from '@noble/ciphers/aes.js';
|
|
2
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
3
|
+
import { mergeUint8 } from '@polkadot-api/utils';
|
|
4
|
+
/**
|
|
5
|
+
* AES-256-GCM encryption for file chunks and metadata.
|
|
6
|
+
* Format: nonce (12 bytes) || ciphertext || tag (16 bytes)
|
|
7
|
+
*/
|
|
8
|
+
export function createFileEncryption(key) {
|
|
9
|
+
return {
|
|
10
|
+
encrypt(data) {
|
|
11
|
+
const nonce = randomBytes(12);
|
|
12
|
+
const aes = gcm(key, nonce);
|
|
13
|
+
return mergeUint8([nonce, aes.encrypt(data)]);
|
|
14
|
+
},
|
|
15
|
+
decrypt(encryptedData) {
|
|
16
|
+
const nonce = encryptedData.slice(0, 12);
|
|
17
|
+
const ciphertext = encryptedData.slice(12);
|
|
18
|
+
const aes = gcm(key, nonce);
|
|
19
|
+
return aes.decrypt(ciphertext);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createFileEncryption } from './encryption.js';
|
|
4
|
+
describe('file encryption', () => {
|
|
5
|
+
it('encrypts and decrypts data', () => {
|
|
6
|
+
const key = randomBytes(32);
|
|
7
|
+
const encryption = createFileEncryption(key);
|
|
8
|
+
const plaintext = new TextEncoder().encode('hello world');
|
|
9
|
+
const encrypted = encryption.encrypt(plaintext);
|
|
10
|
+
const decrypted = encryption.decrypt(encrypted);
|
|
11
|
+
expect(decrypted).toEqual(plaintext);
|
|
12
|
+
});
|
|
13
|
+
it('encrypted data is longer than plaintext (nonce + tag)', () => {
|
|
14
|
+
const key = randomBytes(32);
|
|
15
|
+
const encryption = createFileEncryption(key);
|
|
16
|
+
const plaintext = new Uint8Array(100);
|
|
17
|
+
const encrypted = encryption.encrypt(plaintext);
|
|
18
|
+
// 12 bytes nonce + 100 bytes ciphertext + 16 bytes tag = 128
|
|
19
|
+
expect(encrypted.length).toBe(100 + 12 + 16);
|
|
20
|
+
});
|
|
21
|
+
it('produces different ciphertext for same plaintext (random nonce)', () => {
|
|
22
|
+
const key = randomBytes(32);
|
|
23
|
+
const encryption = createFileEncryption(key);
|
|
24
|
+
const plaintext = new Uint8Array([1, 2, 3]);
|
|
25
|
+
const a = encryption.encrypt(plaintext);
|
|
26
|
+
const b = encryption.encrypt(plaintext);
|
|
27
|
+
expect(a).not.toEqual(b);
|
|
28
|
+
});
|
|
29
|
+
it('fails to decrypt with wrong key', () => {
|
|
30
|
+
const key1 = randomBytes(32);
|
|
31
|
+
const key2 = randomBytes(32);
|
|
32
|
+
const enc1 = createFileEncryption(key1);
|
|
33
|
+
const enc2 = createFileEncryption(key2);
|
|
34
|
+
const plaintext = new TextEncoder().encode('secret');
|
|
35
|
+
const encrypted = enc1.encrypt(plaintext);
|
|
36
|
+
expect(() => enc2.decrypt(encrypted)).toThrow();
|
|
37
|
+
});
|
|
38
|
+
it('handles empty data', () => {
|
|
39
|
+
const key = randomBytes(32);
|
|
40
|
+
const encryption = createFileEncryption(key);
|
|
41
|
+
const plaintext = new Uint8Array(0);
|
|
42
|
+
const encrypted = encryption.encrypt(plaintext);
|
|
43
|
+
const decrypted = encryption.decrypt(encrypted);
|
|
44
|
+
expect(decrypted).toEqual(plaintext);
|
|
45
|
+
});
|
|
46
|
+
it('handles large data', () => {
|
|
47
|
+
const key = randomBytes(32);
|
|
48
|
+
const encryption = createFileEncryption(key);
|
|
49
|
+
const plaintext = new Uint8Array(50_000).fill(0x42);
|
|
50
|
+
const encrypted = encryption.encrypt(plaintext);
|
|
51
|
+
const decrypted = encryption.decrypt(encrypted);
|
|
52
|
+
expect(decrypted).toEqual(plaintext);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { FileEncryption } from './encryption.js';
|
|
2
|
+
export { createFileEncryption } from './encryption.js';
|
|
3
|
+
export type { FileTicket } from './ticket.js';
|
|
4
|
+
export { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type FileTicket = Uint8Array;
|
|
2
|
+
export declare function generateTicket(): FileTicket;
|
|
3
|
+
export declare function deriveSigningSeed(ticket: FileTicket): Uint8Array;
|
|
4
|
+
export declare function deriveSigningKeypair(ticket: FileTicket): Uint8Array;
|
|
5
|
+
export declare function derivePublicKey(ticket: FileTicket): Uint8Array;
|
|
6
|
+
export declare function signWithTicket(ticket: FileTicket, message: Uint8Array): Uint8Array;
|
|
7
|
+
export declare function deriveEncryptionKey(ticket: FileTicket): Uint8Array;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { blake2b } from '@noble/hashes/blake2.js';
|
|
2
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
3
|
+
import { getPublicKey as sr25519GetPublicKey, secretFromSeed as sr25519SecretFromSeed, sign as sr25519Sign, } from '@scure/sr25519';
|
|
4
|
+
const textEncoder = new TextEncoder();
|
|
5
|
+
function khash(secret, message) {
|
|
6
|
+
return blake2b(message, { dkLen: 32, key: secret });
|
|
7
|
+
}
|
|
8
|
+
export function generateTicket() {
|
|
9
|
+
return randomBytes(32);
|
|
10
|
+
}
|
|
11
|
+
export function deriveSigningSeed(ticket) {
|
|
12
|
+
return khash(ticket, textEncoder.encode('signer'));
|
|
13
|
+
}
|
|
14
|
+
export function deriveSigningKeypair(ticket) {
|
|
15
|
+
const seed = deriveSigningSeed(ticket);
|
|
16
|
+
return sr25519SecretFromSeed(seed);
|
|
17
|
+
}
|
|
18
|
+
export function derivePublicKey(ticket) {
|
|
19
|
+
const keypair = deriveSigningKeypair(ticket);
|
|
20
|
+
return sr25519GetPublicKey(keypair);
|
|
21
|
+
}
|
|
22
|
+
export function signWithTicket(ticket, message) {
|
|
23
|
+
const keypair = deriveSigningKeypair(ticket);
|
|
24
|
+
return sr25519Sign(keypair, message);
|
|
25
|
+
}
|
|
26
|
+
export function deriveEncryptionKey(ticket) {
|
|
27
|
+
return khash(ticket, textEncoder.encode('encryption'));
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
|
|
3
|
+
describe('ticket key derivation', () => {
|
|
4
|
+
it('generates a 32-byte ticket', () => {
|
|
5
|
+
const ticket = generateTicket();
|
|
6
|
+
expect(ticket).toBeInstanceOf(Uint8Array);
|
|
7
|
+
expect(ticket.length).toBe(32);
|
|
8
|
+
});
|
|
9
|
+
it('generates unique tickets', () => {
|
|
10
|
+
const a = generateTicket();
|
|
11
|
+
const b = generateTicket();
|
|
12
|
+
expect(a).not.toEqual(b);
|
|
13
|
+
});
|
|
14
|
+
it('derives deterministic signing seed from ticket', () => {
|
|
15
|
+
const ticket = generateTicket();
|
|
16
|
+
const seed1 = deriveSigningSeed(ticket);
|
|
17
|
+
const seed2 = deriveSigningSeed(ticket);
|
|
18
|
+
expect(seed1).toEqual(seed2);
|
|
19
|
+
expect(seed1.length).toBe(32);
|
|
20
|
+
});
|
|
21
|
+
it('derives deterministic signing keypair from ticket', () => {
|
|
22
|
+
const ticket = generateTicket();
|
|
23
|
+
const kp1 = deriveSigningKeypair(ticket);
|
|
24
|
+
const kp2 = deriveSigningKeypair(ticket);
|
|
25
|
+
expect(kp1).toEqual(kp2);
|
|
26
|
+
expect(kp1.length).toBe(64); // sr25519 secret is 64 bytes
|
|
27
|
+
});
|
|
28
|
+
it('derives deterministic public key from ticket', () => {
|
|
29
|
+
const ticket = generateTicket();
|
|
30
|
+
const pk1 = derivePublicKey(ticket);
|
|
31
|
+
const pk2 = derivePublicKey(ticket);
|
|
32
|
+
expect(pk1).toEqual(pk2);
|
|
33
|
+
expect(pk1.length).toBe(32);
|
|
34
|
+
});
|
|
35
|
+
it('derives deterministic encryption key from ticket', () => {
|
|
36
|
+
const ticket = generateTicket();
|
|
37
|
+
const key1 = deriveEncryptionKey(ticket);
|
|
38
|
+
const key2 = deriveEncryptionKey(ticket);
|
|
39
|
+
expect(key1).toEqual(key2);
|
|
40
|
+
expect(key1.length).toBe(32);
|
|
41
|
+
});
|
|
42
|
+
it('signing key and encryption key are different', () => {
|
|
43
|
+
const ticket = generateTicket();
|
|
44
|
+
const sigSeed = deriveSigningSeed(ticket);
|
|
45
|
+
const encKey = deriveEncryptionKey(ticket);
|
|
46
|
+
expect(sigSeed).not.toEqual(encKey);
|
|
47
|
+
});
|
|
48
|
+
it('produces valid sr25519 signature', () => {
|
|
49
|
+
const ticket = generateTicket();
|
|
50
|
+
const message = new Uint8Array([1, 2, 3, 4]);
|
|
51
|
+
const signature = signWithTicket(ticket, message);
|
|
52
|
+
expect(signature).toBeInstanceOf(Uint8Array);
|
|
53
|
+
expect(signature.length).toBe(64);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResultAsync } from 'neverthrow';
|
|
2
|
+
import type { HopClient } from '../rpc/index.js';
|
|
3
|
+
export type UploadParams = {
|
|
4
|
+
data: Uint8Array;
|
|
5
|
+
hopClient: HopClient;
|
|
6
|
+
chunkSize?: number;
|
|
7
|
+
onProgress?: (sent: number, total: number) => void;
|
|
8
|
+
};
|
|
9
|
+
export type UploadResult = {
|
|
10
|
+
identifier: Uint8Array;
|
|
11
|
+
claimTicket: Uint8Array;
|
|
12
|
+
};
|
|
13
|
+
export declare function uploadFile(params: UploadParams): ResultAsync<UploadResult, Error>;
|
|
14
|
+
export type DownloadParams = {
|
|
15
|
+
identifier: Uint8Array;
|
|
16
|
+
claimTicket: Uint8Array;
|
|
17
|
+
hopClient: HopClient;
|
|
18
|
+
onProgress?: (received: number, total: number) => void;
|
|
19
|
+
};
|
|
20
|
+
export declare function downloadFile(params: DownloadParams): ResultAsync<Uint8Array, Error>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { blake2b } from '@noble/hashes/blake2.js';
|
|
2
|
+
import { mergeUint8 } from '@polkadot-api/utils';
|
|
3
|
+
import { errAsync, okAsync } from 'neverthrow';
|
|
4
|
+
import { UploadedFile } from '../codec.js';
|
|
5
|
+
import { createFileEncryption, deriveEncryptionKey, derivePublicKey, generateTicket, signWithTicket, } from '../crypto/index.js';
|
|
6
|
+
const DEFAULT_CHUNK_SIZE = 2_000_000;
|
|
7
|
+
function hash256(data) {
|
|
8
|
+
return blake2b(data, { dkLen: 32 });
|
|
9
|
+
}
|
|
10
|
+
function splitIntoChunks(data, chunkSize) {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
|
13
|
+
chunks.push(data.subarray(offset, offset + chunkSize));
|
|
14
|
+
}
|
|
15
|
+
return chunks;
|
|
16
|
+
}
|
|
17
|
+
export function uploadFile(params) {
|
|
18
|
+
const { data, hopClient, chunkSize = DEFAULT_CHUNK_SIZE, onProgress } = params;
|
|
19
|
+
const ticket = generateTicket();
|
|
20
|
+
const recipientPublicKey = derivePublicKey(ticket);
|
|
21
|
+
const encryptionKey = deriveEncryptionKey(ticket);
|
|
22
|
+
const encryption = createFileEncryption(encryptionKey);
|
|
23
|
+
const recipients = [recipientPublicKey];
|
|
24
|
+
const chunks = splitIntoChunks(data, chunkSize);
|
|
25
|
+
const totalChunks = chunks.length;
|
|
26
|
+
let result = okAsync([]);
|
|
27
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
28
|
+
const chunk = chunks[i];
|
|
29
|
+
result = result.andThen(hashes => {
|
|
30
|
+
const encrypted = encryption.encrypt(chunk);
|
|
31
|
+
return hopClient.submit(encrypted, recipients).map(_poolStatus => {
|
|
32
|
+
const chunkHash = hash256(encrypted);
|
|
33
|
+
onProgress?.(i + 1, totalChunks + 1); // +1 for metadata
|
|
34
|
+
return [...hashes, chunkHash];
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return result.andThen(chunkHashes => {
|
|
39
|
+
const metadata = UploadedFile.enc({
|
|
40
|
+
totalSize: BigInt(data.length),
|
|
41
|
+
chunks: chunkHashes,
|
|
42
|
+
});
|
|
43
|
+
const encryptedMetadata = encryption.encrypt(metadata);
|
|
44
|
+
return hopClient.submit(encryptedMetadata, recipients).map(_poolStatus => {
|
|
45
|
+
const metadataHash = hash256(encryptedMetadata);
|
|
46
|
+
onProgress?.(totalChunks + 1, totalChunks + 1);
|
|
47
|
+
return { identifier: metadataHash, claimTicket: ticket };
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function downloadFile(params) {
|
|
52
|
+
const { identifier, claimTicket, hopClient, onProgress } = params;
|
|
53
|
+
const encryptionKey = deriveEncryptionKey(claimTicket);
|
|
54
|
+
const encryption = createFileEncryption(encryptionKey);
|
|
55
|
+
const metadataSignature = signWithTicket(claimTicket, identifier);
|
|
56
|
+
return hopClient
|
|
57
|
+
.claim(identifier, metadataSignature)
|
|
58
|
+
.andThen(encryptedMetadata => {
|
|
59
|
+
try {
|
|
60
|
+
const metadataBytes = encryption.decrypt(encryptedMetadata);
|
|
61
|
+
const uploadedFile = UploadedFile.dec(metadataBytes);
|
|
62
|
+
return okAsync(uploadedFile);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
return errAsync(new Error(`Failed to decrypt/decode metadata: ${e}`));
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.andThen(uploadedFile => {
|
|
69
|
+
const { totalSize, chunks: chunkHashes } = uploadedFile;
|
|
70
|
+
const totalChunks = chunkHashes.length;
|
|
71
|
+
let result = okAsync([]);
|
|
72
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
73
|
+
const chunkHash = chunkHashes[i];
|
|
74
|
+
result = result.andThen(decryptedChunks => {
|
|
75
|
+
const chunkSignature = signWithTicket(claimTicket, chunkHash);
|
|
76
|
+
return hopClient.claim(chunkHash, chunkSignature).andThen(encryptedChunk => {
|
|
77
|
+
try {
|
|
78
|
+
const chunk = encryption.decrypt(encryptedChunk);
|
|
79
|
+
onProgress?.(i + 1, totalChunks);
|
|
80
|
+
return okAsync([...decryptedChunks, chunk]);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return errAsync(new Error(`Failed to decrypt chunk ${i}: ${e}`));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return result.andThen(decryptedChunks => {
|
|
89
|
+
const reassembled = mergeUint8(decryptedChunks);
|
|
90
|
+
if (BigInt(reassembled.length) !== totalSize) {
|
|
91
|
+
return errAsync(new Error(`File size mismatch: expected ${totalSize}, got ${reassembled.length}`));
|
|
92
|
+
}
|
|
93
|
+
return okAsync(reassembled);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { blake2b } from '@noble/hashes/blake2.js';
|
|
2
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
3
|
+
import { okAsync } from 'neverthrow';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { downloadFile, uploadFile } from './fileLoader.js';
|
|
6
|
+
function hash256(data) {
|
|
7
|
+
return blake2b(data, { dkLen: 32 });
|
|
8
|
+
}
|
|
9
|
+
function createMockHopClient() {
|
|
10
|
+
const submittedEntries = new Map();
|
|
11
|
+
function hashKey(hash) {
|
|
12
|
+
return Array.from(hash)
|
|
13
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
14
|
+
.join('');
|
|
15
|
+
}
|
|
16
|
+
const submitMock = vi.fn();
|
|
17
|
+
const claimMock = vi.fn();
|
|
18
|
+
submitMock.mockImplementation((data, _recipients) => {
|
|
19
|
+
const h = hash256(data);
|
|
20
|
+
submittedEntries.set(hashKey(h), new Uint8Array(data));
|
|
21
|
+
return okAsync({ entryCount: submittedEntries.size, totalBytes: 0, maxBytes: 10_000_000 });
|
|
22
|
+
});
|
|
23
|
+
claimMock.mockImplementation((hash, _signature) => {
|
|
24
|
+
const key = hashKey(hash);
|
|
25
|
+
const entry = submittedEntries.get(key);
|
|
26
|
+
if (!entry) {
|
|
27
|
+
throw new Error(`No entry for hash ${key}`);
|
|
28
|
+
}
|
|
29
|
+
submittedEntries.delete(key);
|
|
30
|
+
return okAsync(entry);
|
|
31
|
+
});
|
|
32
|
+
const client = {
|
|
33
|
+
submit: submitMock,
|
|
34
|
+
claim: claimMock,
|
|
35
|
+
poolStatus: vi.fn(() => okAsync({ entryCount: 0, totalBytes: 0, maxBytes: 10_000_000 })),
|
|
36
|
+
};
|
|
37
|
+
return { client, submitMock, claimMock, submittedEntries };
|
|
38
|
+
}
|
|
39
|
+
describe('file loader', () => {
|
|
40
|
+
it('uploads and downloads a small file (single chunk)', async () => {
|
|
41
|
+
const { client, submitMock } = createMockHopClient();
|
|
42
|
+
const data = new TextEncoder().encode('hello world');
|
|
43
|
+
const uploadResult = await uploadFile({ data, hopClient: client });
|
|
44
|
+
expect(uploadResult.isOk()).toBe(true);
|
|
45
|
+
const { identifier, claimTicket } = uploadResult._unsafeUnwrap();
|
|
46
|
+
expect(identifier.length).toBe(32);
|
|
47
|
+
expect(claimTicket.length).toBe(32);
|
|
48
|
+
// 1 chunk + 1 metadata = 2 submissions
|
|
49
|
+
expect(submitMock).toHaveBeenCalledTimes(2);
|
|
50
|
+
const downloadResult = await downloadFile({
|
|
51
|
+
identifier,
|
|
52
|
+
claimTicket,
|
|
53
|
+
hopClient: client,
|
|
54
|
+
});
|
|
55
|
+
expect(downloadResult.isOk()).toBe(true);
|
|
56
|
+
expect(downloadResult._unsafeUnwrap()).toEqual(data);
|
|
57
|
+
});
|
|
58
|
+
it('uploads and downloads a multi-chunk file', async () => {
|
|
59
|
+
const { client, submitMock } = createMockHopClient();
|
|
60
|
+
const data = randomBytes(5_000); // 5KB with 2KB chunk size
|
|
61
|
+
const uploadResult = await uploadFile({
|
|
62
|
+
data,
|
|
63
|
+
hopClient: client,
|
|
64
|
+
chunkSize: 2_000,
|
|
65
|
+
});
|
|
66
|
+
expect(uploadResult.isOk()).toBe(true);
|
|
67
|
+
const { identifier, claimTicket } = uploadResult._unsafeUnwrap();
|
|
68
|
+
// 3 chunks (2000 + 2000 + 1000) + 1 metadata = 4 submissions
|
|
69
|
+
expect(submitMock).toHaveBeenCalledTimes(4);
|
|
70
|
+
const downloadResult = await downloadFile({
|
|
71
|
+
identifier,
|
|
72
|
+
claimTicket,
|
|
73
|
+
hopClient: client,
|
|
74
|
+
});
|
|
75
|
+
expect(downloadResult.isOk()).toBe(true);
|
|
76
|
+
expect(downloadResult._unsafeUnwrap()).toEqual(data);
|
|
77
|
+
});
|
|
78
|
+
it('reports upload progress', async () => {
|
|
79
|
+
const { client } = createMockHopClient();
|
|
80
|
+
const data = randomBytes(5_000);
|
|
81
|
+
const progress = [];
|
|
82
|
+
await uploadFile({
|
|
83
|
+
data,
|
|
84
|
+
hopClient: client,
|
|
85
|
+
chunkSize: 2_000,
|
|
86
|
+
onProgress: (sent, total) => progress.push([sent, total]),
|
|
87
|
+
});
|
|
88
|
+
// 3 chunks + 1 metadata = total 4 steps
|
|
89
|
+
expect(progress).toEqual([
|
|
90
|
+
[1, 4],
|
|
91
|
+
[2, 4],
|
|
92
|
+
[3, 4],
|
|
93
|
+
[4, 4],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
it('reports download progress', async () => {
|
|
97
|
+
const { client } = createMockHopClient();
|
|
98
|
+
const data = randomBytes(5_000);
|
|
99
|
+
const { identifier, claimTicket } = (await uploadFile({ data, hopClient: client, chunkSize: 2_000 }))._unsafeUnwrap();
|
|
100
|
+
const progress = [];
|
|
101
|
+
await downloadFile({
|
|
102
|
+
identifier,
|
|
103
|
+
claimTicket,
|
|
104
|
+
hopClient: client,
|
|
105
|
+
onProgress: (received, total) => progress.push([received, total]),
|
|
106
|
+
});
|
|
107
|
+
expect(progress).toEqual([
|
|
108
|
+
[1, 3],
|
|
109
|
+
[2, 3],
|
|
110
|
+
[3, 3],
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
it('handles empty file', async () => {
|
|
114
|
+
const { client } = createMockHopClient();
|
|
115
|
+
const data = new Uint8Array(0);
|
|
116
|
+
const uploadResult = await uploadFile({ data, hopClient: client });
|
|
117
|
+
expect(uploadResult.isOk()).toBe(true);
|
|
118
|
+
const { identifier, claimTicket } = uploadResult._unsafeUnwrap();
|
|
119
|
+
const downloadResult = await downloadFile({
|
|
120
|
+
identifier,
|
|
121
|
+
claimTicket,
|
|
122
|
+
hopClient: client,
|
|
123
|
+
});
|
|
124
|
+
expect(downloadResult.isOk()).toBe(true);
|
|
125
|
+
expect(downloadResult._unsafeUnwrap()).toEqual(data);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { downloadFile, uploadFile } from './fileLoader.js';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { HopClient } from './rpc/index.js';
|
|
2
|
+
export { createHopClient } from './rpc/index.js';
|
|
3
|
+
export type { HexString, PoolStatus, RequestFn } from './rpc/index.js';
|
|
4
|
+
export type { FileEncryption, FileTicket } from './crypto/index.js';
|
|
5
|
+
export { createFileEncryption, deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './crypto/index.js';
|
|
6
|
+
export type { DownloadParams, UploadParams, UploadResult } from './fileLoader/index.js';
|
|
7
|
+
export { downloadFile, uploadFile } from './fileLoader/index.js';
|
|
8
|
+
export { UploadedFile } from './codec.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createHopClient } from './rpc/index.js';
|
|
2
|
+
export { createFileEncryption, deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './crypto/index.js';
|
|
3
|
+
export { downloadFile, uploadFile } from './fileLoader/index.js';
|
|
4
|
+
// Codec (internal pool metadata)
|
|
5
|
+
export { UploadedFile } from './codec.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResultAsync } from 'neverthrow';
|
|
2
|
+
import type { PoolStatus, RequestFn } from './types.js';
|
|
3
|
+
export type HopClient = {
|
|
4
|
+
submit(data: Uint8Array, recipients: Uint8Array[]): ResultAsync<PoolStatus, Error>;
|
|
5
|
+
claim(hash: Uint8Array, signature: Uint8Array): ResultAsync<Uint8Array, Error>;
|
|
6
|
+
poolStatus(): ResultAsync<PoolStatus, Error>;
|
|
7
|
+
};
|
|
8
|
+
export declare function createHopClient(requestFn: RequestFn): HopClient;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fromHex, toHex } from '@polkadot-api/utils';
|
|
2
|
+
import { fromPromise } from 'neverthrow';
|
|
3
|
+
import { MultiSignature, MultiSigner } from './scale.js';
|
|
4
|
+
function toHexString(bytes) {
|
|
5
|
+
return toHex(bytes);
|
|
6
|
+
}
|
|
7
|
+
function encodeSr25519Signer(publicKey) {
|
|
8
|
+
return toHexString(MultiSigner.enc({ tag: 'sr25519', value: publicKey }));
|
|
9
|
+
}
|
|
10
|
+
function encodeSr25519Signature(signature) {
|
|
11
|
+
return toHexString(MultiSignature.enc({ tag: 'sr25519', value: signature }));
|
|
12
|
+
}
|
|
13
|
+
function toError(e) {
|
|
14
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
15
|
+
}
|
|
16
|
+
export function createHopClient(requestFn) {
|
|
17
|
+
return {
|
|
18
|
+
submit(data, recipients) {
|
|
19
|
+
const encodedRecipients = recipients.map(r => encodeSr25519Signer(r));
|
|
20
|
+
return fromPromise(requestFn('hop_submit', [toHexString(data), encodedRecipients, '0x']), toError);
|
|
21
|
+
},
|
|
22
|
+
claim(hash, signature) {
|
|
23
|
+
return fromPromise(requestFn('hop_claim', [toHexString(hash), encodeSr25519Signature(signature)]).then(hex => fromHex(hex)), toError);
|
|
24
|
+
},
|
|
25
|
+
poolStatus() {
|
|
26
|
+
return fromPromise(requestFn('hop_poolStatus', []), toError);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createHopClient } from './client.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCALE-encoded MultiSigner:
|
|
3
|
+
* 0 = Ed25519 (32 bytes)
|
|
4
|
+
* 1 = SR25519 (32 bytes)
|
|
5
|
+
* 2 = ECDSA (33 bytes)
|
|
6
|
+
*/
|
|
7
|
+
export declare const MultiSigner: import("scale-ts").Codec<{
|
|
8
|
+
tag: "ed25519";
|
|
9
|
+
value: Uint8Array<ArrayBufferLike>;
|
|
10
|
+
} | {
|
|
11
|
+
tag: "sr25519";
|
|
12
|
+
value: Uint8Array<ArrayBufferLike>;
|
|
13
|
+
} | {
|
|
14
|
+
tag: "ecdsa";
|
|
15
|
+
value: Uint8Array<ArrayBufferLike>;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* SCALE-encoded MultiSignature:
|
|
19
|
+
* 0 = Ed25519 (64 bytes)
|
|
20
|
+
* 1 = SR25519 (64 bytes)
|
|
21
|
+
* 2 = ECDSA (65 bytes)
|
|
22
|
+
*/
|
|
23
|
+
export declare const MultiSignature: import("scale-ts").Codec<{
|
|
24
|
+
tag: "ed25519";
|
|
25
|
+
value: Uint8Array<ArrayBufferLike>;
|
|
26
|
+
} | {
|
|
27
|
+
tag: "sr25519";
|
|
28
|
+
value: Uint8Array<ArrayBufferLike>;
|
|
29
|
+
} | {
|
|
30
|
+
tag: "ecdsa";
|
|
31
|
+
value: Uint8Array<ArrayBufferLike>;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Bytes, Enum } from 'scale-ts';
|
|
2
|
+
/**
|
|
3
|
+
* SCALE-encoded MultiSigner:
|
|
4
|
+
* 0 = Ed25519 (32 bytes)
|
|
5
|
+
* 1 = SR25519 (32 bytes)
|
|
6
|
+
* 2 = ECDSA (33 bytes)
|
|
7
|
+
*/
|
|
8
|
+
export const MultiSigner = Enum({
|
|
9
|
+
ed25519: Bytes(32),
|
|
10
|
+
sr25519: Bytes(32),
|
|
11
|
+
ecdsa: Bytes(33),
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* SCALE-encoded MultiSignature:
|
|
15
|
+
* 0 = Ed25519 (64 bytes)
|
|
16
|
+
* 1 = SR25519 (64 bytes)
|
|
17
|
+
* 2 = ECDSA (65 bytes)
|
|
18
|
+
*/
|
|
19
|
+
export const MultiSignature = Enum({
|
|
20
|
+
ed25519: Bytes(64),
|
|
21
|
+
sr25519: Bytes(64),
|
|
22
|
+
ecdsa: Bytes(65),
|
|
23
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type HexString = `0x${string}`;
|
|
2
|
+
export type PoolStatus = {
|
|
3
|
+
entryCount: number;
|
|
4
|
+
totalBytes: number;
|
|
5
|
+
maxBytes: number;
|
|
6
|
+
};
|
|
7
|
+
export type SubmitParams = {
|
|
8
|
+
data: HexString;
|
|
9
|
+
recipients: HexString[];
|
|
10
|
+
proof: HexString;
|
|
11
|
+
};
|
|
12
|
+
export type ClaimParams = {
|
|
13
|
+
hash: HexString;
|
|
14
|
+
signature: HexString;
|
|
15
|
+
};
|
|
16
|
+
export type RequestFn = <Reply>(method: string, params: unknown[]) => Promise<Reply>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@novasamatech/handoff-service",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.6.18",
|
|
5
|
+
"description": "HOP (Handoff Pool) file transfer service for P2P chat",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/paritytech/triangle-js-sdks.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"polkadot"
|
|
13
|
+
],
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
"./package.json": "./package.json",
|
|
17
|
+
".": {
|
|
18
|
+
"#/source": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@novasamatech/scale": "0.6.18",
|
|
29
|
+
"@noble/ciphers": "2.2.0",
|
|
30
|
+
"@noble/hashes": "2.2.0",
|
|
31
|
+
"@polkadot-api/utils": "0.2.0",
|
|
32
|
+
"@scure/sr25519": "1.0.0",
|
|
33
|
+
"neverthrow": "^8.2.0",
|
|
34
|
+
"scale-ts": "1.6.1"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|