web3ql-client 1.2.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 +66 -0
- package/contracts/PublicKeyRegistry.sol +87 -0
- package/dist/src/access.d.ts +176 -0
- package/dist/src/access.d.ts.map +1 -0
- package/dist/src/access.js +283 -0
- package/dist/src/access.js.map +1 -0
- package/dist/src/batch.d.ts +107 -0
- package/dist/src/batch.d.ts.map +1 -0
- package/dist/src/batch.js +188 -0
- package/dist/src/batch.js.map +1 -0
- package/dist/src/cli.d.ts +40 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +361 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/constraints.d.ts +126 -0
- package/dist/src/constraints.d.ts.map +1 -0
- package/dist/src/constraints.js +192 -0
- package/dist/src/constraints.js.map +1 -0
- package/dist/src/crypto.d.ts +118 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +192 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/factory-client.d.ts +106 -0
- package/dist/src/factory-client.d.ts.map +1 -0
- package/dist/src/factory-client.js +202 -0
- package/dist/src/factory-client.js.map +1 -0
- package/dist/src/index-cache.d.ts +156 -0
- package/dist/src/index-cache.d.ts.map +1 -0
- package/dist/src/index-cache.js +265 -0
- package/dist/src/index-cache.js.map +1 -0
- package/dist/src/index.d.ts +60 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +60 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/migrations.d.ts +114 -0
- package/dist/src/migrations.d.ts.map +1 -0
- package/dist/src/migrations.js +173 -0
- package/dist/src/migrations.js.map +1 -0
- package/dist/src/model.d.ts +198 -0
- package/dist/src/model.d.ts.map +1 -0
- package/dist/src/model.js +379 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/query.d.ts +155 -0
- package/dist/src/query.d.ts.map +1 -0
- package/dist/src/query.js +386 -0
- package/dist/src/query.js.map +1 -0
- package/dist/src/registry.d.ts +45 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +80 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/schema-manager.d.ts +109 -0
- package/dist/src/schema-manager.d.ts.map +1 -0
- package/dist/src/schema-manager.js +259 -0
- package/dist/src/schema-manager.js.map +1 -0
- package/dist/src/table-client.d.ts +156 -0
- package/dist/src/table-client.d.ts.map +1 -0
- package/dist/src/table-client.js +292 -0
- package/dist/src/table-client.js.map +1 -0
- package/dist/src/typed-table.d.ts +159 -0
- package/dist/src/typed-table.d.ts.map +1 -0
- package/dist/src/typed-table.js +246 -0
- package/dist/src/typed-table.js.map +1 -0
- package/dist/src/types.d.ts +48 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +222 -0
- package/dist/src/types.js.map +1 -0
- package/keyManager.js +337 -0
- package/package.json +38 -0
- package/src/access.ts +421 -0
- package/src/batch.ts +259 -0
- package/src/cli.ts +349 -0
- package/src/constraints.ts +283 -0
- package/src/crypto.ts +239 -0
- package/src/factory-client.ts +237 -0
- package/src/index-cache.ts +351 -0
- package/src/index.ts +171 -0
- package/src/migrations.ts +215 -0
- package/src/model.ts +538 -0
- package/src/query.ts +508 -0
- package/src/registry.ts +100 -0
- package/src/schema-manager.ts +301 -0
- package/src/table-client.ts +393 -0
- package/src/typed-table.ts +340 -0
- package/src/types.ts +284 -0
- package/tsconfig.json +22 -0
- package/walletUtils.js +204 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file schema-manager.ts
|
|
3
|
+
* @notice Web3QL v1.2 — schema management: drop tables, rename, introspection.
|
|
4
|
+
*
|
|
5
|
+
* Schema is stored as ABI-encoded bytes in the database contract.
|
|
6
|
+
* This module provides:
|
|
7
|
+
*
|
|
8
|
+
* 1. SCHEMA INTROSPECTION — decode raw schema bytes → FieldDescriptor[]
|
|
9
|
+
* 2. DROP TABLE — bulk delete all owner records, then rename table to __dropped__
|
|
10
|
+
* 3. RENAME TABLE — soft-rename via a meta record (contract doesn't support rename natively)
|
|
11
|
+
* 4. SCHEMA DIFF — compare two schemas, produce a list of changes
|
|
12
|
+
* 5. SCHEMA VERSION — read + write the schema version stored in a meta record
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* ─────────────────────────────────────────────────────────────
|
|
16
|
+
* const mgr = new SchemaManager(db, tableAddress, tableClient);
|
|
17
|
+
*
|
|
18
|
+
* // Inspect a deployed table's schema
|
|
19
|
+
* const fields = await mgr.introspect();
|
|
20
|
+
*
|
|
21
|
+
* // Diff two versions
|
|
22
|
+
* const changes = diffSchema(oldFields, newFields);
|
|
23
|
+
*
|
|
24
|
+
* // Soft-drop: mark table as dropped + purge all owner records
|
|
25
|
+
* await mgr.dropTable(ownerAddress);
|
|
26
|
+
*
|
|
27
|
+
* // Soft-rename: store alias mapping in meta record
|
|
28
|
+
* await mgr.renameTable('users', 'app_users');
|
|
29
|
+
* ─────────────────────────────────────────────────────────────
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { ethers } from 'ethers';
|
|
33
|
+
import type { DatabaseClient } from './factory-client.js';
|
|
34
|
+
import type { EncryptedTableClient } from './table-client.js';
|
|
35
|
+
import type { FieldDescriptor } from './types.js';
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────
|
|
38
|
+
// Schema introspection
|
|
39
|
+
// ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Minimal ABI type tags used in Web3QL schema encoding.
|
|
43
|
+
* Must stay in sync with protocol/compiler/generator.ts.
|
|
44
|
+
*/
|
|
45
|
+
const SOLIDITY_TO_FIELD_TYPE: Record<string, string> = {
|
|
46
|
+
'uint256': 'INT',
|
|
47
|
+
'int256' : 'INT',
|
|
48
|
+
'int64' : 'INT',
|
|
49
|
+
'uint64' : 'UINT64',
|
|
50
|
+
'uint32' : 'UINT32',
|
|
51
|
+
'uint16' : 'UINT16',
|
|
52
|
+
'uint8' : 'UINT8',
|
|
53
|
+
'string' : 'TEXT',
|
|
54
|
+
'bool' : 'BOOL',
|
|
55
|
+
'address': 'ADDRESS',
|
|
56
|
+
'bytes32': 'BYTES32',
|
|
57
|
+
'bytes' : 'BYTES32',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decode raw schema bytes from the contract into an array of FieldDescriptors.
|
|
62
|
+
*
|
|
63
|
+
* Web3QL encodes schema as ABI-encoded:
|
|
64
|
+
* tuple(string name, string solidityType, bool primaryKey, bool notNull)[]
|
|
65
|
+
*
|
|
66
|
+
* This mirrors protocol/compiler/generator.ts compileSchema().
|
|
67
|
+
*/
|
|
68
|
+
export function decodeSchemaBytes(schemaBytes: string | Uint8Array): FieldDescriptor[] {
|
|
69
|
+
try {
|
|
70
|
+
const bytes = typeof schemaBytes === 'string' ? ethers.getBytes(schemaBytes) : schemaBytes;
|
|
71
|
+
if (bytes.length === 0) return [];
|
|
72
|
+
|
|
73
|
+
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
|
|
74
|
+
const decoded = abiCoder.decode(
|
|
75
|
+
['tuple(string name, string solidityType, bool primaryKey, bool notNull)[]'],
|
|
76
|
+
bytes,
|
|
77
|
+
);
|
|
78
|
+
const fields = decoded[0] as { name: string; solidityType: string; primaryKey: boolean; notNull: boolean }[];
|
|
79
|
+
return fields.map((f) => ({
|
|
80
|
+
name : f.name,
|
|
81
|
+
type : (SOLIDITY_TO_FIELD_TYPE[f.solidityType] ?? 'TEXT') as FieldDescriptor['type'],
|
|
82
|
+
primaryKey: f.primaryKey || undefined,
|
|
83
|
+
notNull : f.notNull || undefined,
|
|
84
|
+
}));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────
|
|
91
|
+
// Schema diff
|
|
92
|
+
// ─────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export type SchemaChangeType = 'added' | 'dropped' | 'typeChanged' | 'notNullChanged';
|
|
95
|
+
|
|
96
|
+
export interface SchemaChange {
|
|
97
|
+
type : SchemaChangeType;
|
|
98
|
+
column : string;
|
|
99
|
+
oldValue?: string;
|
|
100
|
+
newValue?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compute the diff between two schema versions.
|
|
105
|
+
* Returns an ordered list of changes from `from` → `to`.
|
|
106
|
+
*/
|
|
107
|
+
export function diffSchema(
|
|
108
|
+
from: FieldDescriptor[],
|
|
109
|
+
to : FieldDescriptor[],
|
|
110
|
+
): SchemaChange[] {
|
|
111
|
+
const changes: SchemaChange[] = [];
|
|
112
|
+
const fromMap = new Map(from.map((f) => [f.name, f]));
|
|
113
|
+
const toMap = new Map(to.map((f) => [f.name, f]));
|
|
114
|
+
|
|
115
|
+
// Added or changed
|
|
116
|
+
for (const [name, toField] of toMap) {
|
|
117
|
+
const fromField = fromMap.get(name);
|
|
118
|
+
if (!fromField) {
|
|
119
|
+
changes.push({ type: 'added', column: name, newValue: toField.type });
|
|
120
|
+
} else {
|
|
121
|
+
if (fromField.type !== toField.type) {
|
|
122
|
+
changes.push({ type: 'typeChanged', column: name, oldValue: fromField.type, newValue: toField.type });
|
|
123
|
+
}
|
|
124
|
+
if (Boolean(fromField.notNull) !== Boolean(toField.notNull)) {
|
|
125
|
+
changes.push({
|
|
126
|
+
type : 'notNullChanged',
|
|
127
|
+
column : name,
|
|
128
|
+
oldValue: fromField.notNull ? 'NOT NULL' : 'NULLABLE',
|
|
129
|
+
newValue: toField.notNull ? 'NOT NULL' : 'NULLABLE',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Dropped
|
|
136
|
+
for (const name of fromMap.keys()) {
|
|
137
|
+
if (!toMap.has(name)) {
|
|
138
|
+
changes.push({ type: 'dropped', column: name });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return changes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─────────────────────────────────────────────────────────────
|
|
146
|
+
// SchemaManager
|
|
147
|
+
// ─────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const DATABASE_INTROSPECT_ABI = [
|
|
150
|
+
'function getTableSchema(string calldata name) external view returns (bytes memory)',
|
|
151
|
+
'function getTable(string calldata name) external view returns (address)',
|
|
152
|
+
'function listTables() external view returns (string[] memory)',
|
|
153
|
+
] as const;
|
|
154
|
+
|
|
155
|
+
export class SchemaManager {
|
|
156
|
+
private db : DatabaseClient;
|
|
157
|
+
private tableAddress: string;
|
|
158
|
+
private tableClient : EncryptedTableClient;
|
|
159
|
+
private dbContract : ethers.Contract;
|
|
160
|
+
|
|
161
|
+
constructor(
|
|
162
|
+
db : DatabaseClient,
|
|
163
|
+
tableAddress: string,
|
|
164
|
+
tableClient : EncryptedTableClient,
|
|
165
|
+
signer : ethers.Signer,
|
|
166
|
+
) {
|
|
167
|
+
this.db = db;
|
|
168
|
+
this.tableAddress = tableAddress;
|
|
169
|
+
this.tableClient = tableClient;
|
|
170
|
+
this.dbContract = new ethers.Contract(db.address, DATABASE_INTROSPECT_ABI, signer);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Introspection ───────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read the schema bytes from the database contract and decode them
|
|
177
|
+
* into a usable FieldDescriptor array.
|
|
178
|
+
*
|
|
179
|
+
* @param tableName The name used when the table was created.
|
|
180
|
+
*/
|
|
181
|
+
async introspect(tableName: string): Promise<FieldDescriptor[]> {
|
|
182
|
+
try {
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
const schemaBytes = await (this.dbContract as any).getTableSchema(tableName) as string;
|
|
185
|
+
return decodeSchemaBytes(schemaBytes);
|
|
186
|
+
} catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List all table names in this database.
|
|
193
|
+
*/
|
|
194
|
+
async listTables(): Promise<string[]> {
|
|
195
|
+
return this.db.listTables();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Schema version tracking ─────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read the schema version stored in a meta record on-chain.
|
|
202
|
+
* Returns 0 if no version record has been written yet.
|
|
203
|
+
*/
|
|
204
|
+
async getSchemaVersion(tableName: string): Promise<number> {
|
|
205
|
+
const versionKey = this.tableClient.deriveKey(`__schema_version__${tableName}`, 0n);
|
|
206
|
+
try {
|
|
207
|
+
const exists = await this.tableClient.exists(versionKey);
|
|
208
|
+
if (!exists) return 0;
|
|
209
|
+
const json = await this.tableClient.readPlaintext(versionKey);
|
|
210
|
+
const { version } = JSON.parse(json) as { version: number };
|
|
211
|
+
return version;
|
|
212
|
+
} catch {
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write (or update) the schema version meta record.
|
|
219
|
+
*/
|
|
220
|
+
async setSchemaVersion(tableName: string, version: number): Promise<void> {
|
|
221
|
+
const versionKey = this.tableClient.deriveKey(`__schema_version__${tableName}`, 0n);
|
|
222
|
+
const payload = JSON.stringify({ version, updatedAt: Date.now() });
|
|
223
|
+
const exists = await this.tableClient.exists(versionKey);
|
|
224
|
+
if (exists) {
|
|
225
|
+
await this.tableClient.updateRaw(versionKey, payload);
|
|
226
|
+
} else {
|
|
227
|
+
await this.tableClient.writeRaw(versionKey, payload);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Soft-rename ─────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Store a name alias mapping in a meta record.
|
|
235
|
+
* Future `introspect()` calls should use the new name.
|
|
236
|
+
*
|
|
237
|
+
* ⚠ The contract still uses the old name internally. This is a
|
|
238
|
+
* client-side alias only. To hard-rename, re-create the table.
|
|
239
|
+
*/
|
|
240
|
+
async renameTable(oldName: string, newName: string): Promise<void> {
|
|
241
|
+
const renameKey = this.tableClient.deriveKey(`__rename__${oldName}`, 0n);
|
|
242
|
+
const payload = JSON.stringify({ from: oldName, to: newName, renamedAt: Date.now() });
|
|
243
|
+
const exists = await this.tableClient.exists(renameKey);
|
|
244
|
+
if (exists) {
|
|
245
|
+
await this.tableClient.updateRaw(renameKey, payload);
|
|
246
|
+
} else {
|
|
247
|
+
await this.tableClient.writeRaw(renameKey, payload);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Check if a table has been soft-renamed. Returns the new name or null. */
|
|
252
|
+
async getRenamedTo(tableName: string): Promise<string | null> {
|
|
253
|
+
const renameKey = this.tableClient.deriveKey(`__rename__${tableName}`, 0n);
|
|
254
|
+
try {
|
|
255
|
+
const exists = await this.tableClient.exists(renameKey);
|
|
256
|
+
if (!exists) return null;
|
|
257
|
+
const json = await this.tableClient.readPlaintext(renameKey);
|
|
258
|
+
const { to } = JSON.parse(json) as { to: string };
|
|
259
|
+
return to;
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Soft-drop ───────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* "Drop" a table by:
|
|
269
|
+
* 1. Deleting all owner records (batch, up to `maxRecords`).
|
|
270
|
+
* 2. Writing a __dropped__ meta record so the SDK knows to ignore it.
|
|
271
|
+
*
|
|
272
|
+
* ⚠ This is irreversible. The on-chain key->ciphertext mapping for
|
|
273
|
+
* deleted records is permanently unreadable (symmetric key scrubbed).
|
|
274
|
+
*
|
|
275
|
+
* @param ownerAddress Address whose records to delete.
|
|
276
|
+
* @param maxRecords Safety cap. Default: 500. Increase for large tables.
|
|
277
|
+
*/
|
|
278
|
+
async dropTable(ownerAddress: string, maxRecords = 500): Promise<{ deleted: number }> {
|
|
279
|
+
const keys = await this.tableClient.listOwnerRecords(ownerAddress, 0n, BigInt(maxRecords));
|
|
280
|
+
let deleted = 0;
|
|
281
|
+
for (const key of keys) {
|
|
282
|
+
try {
|
|
283
|
+
await this.tableClient.deleteRecord(key);
|
|
284
|
+
deleted++;
|
|
285
|
+
} catch { /* already deleted or no access — skip */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Write tombstone
|
|
289
|
+
const tombstoneKey = this.tableClient.deriveKey(`__dropped__${this.tableAddress}`, 0n);
|
|
290
|
+
const payload = JSON.stringify({ droppedAt: Date.now(), ownerAddress });
|
|
291
|
+
await this.tableClient.writeRaw(tombstoneKey, payload);
|
|
292
|
+
|
|
293
|
+
return { deleted };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Check if a table has been soft-dropped. */
|
|
297
|
+
async isDropped(): Promise<boolean> {
|
|
298
|
+
const tombstoneKey = this.tableClient.deriveKey(`__dropped__${this.tableAddress}`, 0n);
|
|
299
|
+
return this.tableClient.exists(tombstoneKey);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file table-client.ts
|
|
3
|
+
* @notice Base encrypted table client.
|
|
4
|
+
*
|
|
5
|
+
* This class wraps a Web3QL table contract proxy and handles ALL
|
|
6
|
+
* encryption/decryption client-side. The chain only ever sees:
|
|
7
|
+
* • ciphertext blobs (AES-equivalent via NaCl secretbox)
|
|
8
|
+
* • per-user encrypted key blobs (NaCl box)
|
|
9
|
+
*
|
|
10
|
+
* Plaintext and symmetric keys never leave this class.
|
|
11
|
+
*
|
|
12
|
+
* Usage pattern:
|
|
13
|
+
* ─────────────────────────────────────────────────────────────
|
|
14
|
+
* const client = new EncryptedTableClient(tableAddress, signer, keypair);
|
|
15
|
+
*
|
|
16
|
+
* // Write — encrypts automatically
|
|
17
|
+
* await client.write(1n, JSON.stringify({ name: 'Alice' }));
|
|
18
|
+
*
|
|
19
|
+
* // Read — decrypts automatically
|
|
20
|
+
* const data = await client.read(1n); // '{"name":"Alice"}'
|
|
21
|
+
*
|
|
22
|
+
* // Share with Bob (needs Bob registered in registry)
|
|
23
|
+
* await client.share(1n, bobAddress, Role.VIEWER, registry);
|
|
24
|
+
*
|
|
25
|
+
* // Revoke
|
|
26
|
+
* await client.revoke(1n, bobAddress);
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { ethers } from 'ethers';
|
|
30
|
+
import {
|
|
31
|
+
EncryptionKeypair,
|
|
32
|
+
generateSymmetricKey,
|
|
33
|
+
encryptData,
|
|
34
|
+
decryptData,
|
|
35
|
+
encryptKeyForSelf,
|
|
36
|
+
encryptKeyForRecipient,
|
|
37
|
+
decryptKeyForSelf,
|
|
38
|
+
decryptKeyFromSender,
|
|
39
|
+
} from './crypto.js';
|
|
40
|
+
import type { PublicKeyRegistryClient } from './registry.js';
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────
|
|
43
|
+
// Types
|
|
44
|
+
// ─────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export enum Role {
|
|
47
|
+
VIEWER = 1,
|
|
48
|
+
EDITOR = 2,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RawRecord {
|
|
52
|
+
ciphertext : Uint8Array;
|
|
53
|
+
deleted : boolean;
|
|
54
|
+
version : bigint;
|
|
55
|
+
updatedAt : bigint;
|
|
56
|
+
owner : string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─────────────────────────────────────────────────────────────
|
|
60
|
+
// Minimal ABI — functions shared by all Web3QL table contracts
|
|
61
|
+
// ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const TABLE_ABI = [
|
|
64
|
+
// core
|
|
65
|
+
'function write(bytes32 key, bytes calldata ciphertext, bytes calldata encryptedKey) external',
|
|
66
|
+
'function read(bytes32 key) external view returns (bytes memory ciphertext, bool deleted, uint256 version, uint256 updatedAt, address owner)',
|
|
67
|
+
'function update(bytes32 key, bytes calldata ciphertext, bytes calldata encryptedKey) external',
|
|
68
|
+
'function deleteRecord(bytes32 key) external',
|
|
69
|
+
// key management
|
|
70
|
+
'function getMyEncryptedKey(bytes32 key) external view returns (bytes memory)',
|
|
71
|
+
// access control
|
|
72
|
+
'function grantAccess(bytes32 key, address user, uint8 role, bytes calldata encryptedKeyForUser) external',
|
|
73
|
+
'function revokeAccess(bytes32 key, address user) external',
|
|
74
|
+
// views
|
|
75
|
+
'function recordExists(bytes32 key) external view returns (bool)',
|
|
76
|
+
'function recordOwner(bytes32 key) external view returns (address)',
|
|
77
|
+
'function collaboratorCount(bytes32 key) external view returns (uint8)',
|
|
78
|
+
'function getCollaborators(bytes32 key) external view returns (address[] memory)',
|
|
79
|
+
'function getRole(bytes32 resource, address user) external view returns (uint8)',
|
|
80
|
+
// owner record enumeration
|
|
81
|
+
'function ownerRecordCount(address addr) external view returns (uint256)',
|
|
82
|
+
'function getOwnerRecords(address addr, uint256 start, uint256 limit) external view returns (bytes32[] memory)',
|
|
83
|
+
'function getActiveOwnerRecords(address addr, uint256 start, uint256 limit) external view returns (bytes32[] memory)',
|
|
84
|
+
// table metadata
|
|
85
|
+
'function tableName() external view returns (string memory)',
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────
|
|
89
|
+
// EncryptedTableClient
|
|
90
|
+
// ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export class EncryptedTableClient {
|
|
93
|
+
readonly tableAddress : string;
|
|
94
|
+
protected contract : ethers.Contract;
|
|
95
|
+
protected signer : ethers.Signer;
|
|
96
|
+
|
|
97
|
+
/** The caller's X25519 keypair — private key STAYS in memory only. */
|
|
98
|
+
private keypair : EncryptionKeypair;
|
|
99
|
+
/** Shorthand for casting contract to any so strict index checks don't block calls. */
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
private get c(): any { return this.contract; }
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
tableAddress : string,
|
|
105
|
+
signer : ethers.Signer,
|
|
106
|
+
keypair : EncryptionKeypair,
|
|
107
|
+
abi : readonly string[] = TABLE_ABI,
|
|
108
|
+
) {
|
|
109
|
+
this.tableAddress = tableAddress;
|
|
110
|
+
this.signer = signer;
|
|
111
|
+
this.keypair = keypair;
|
|
112
|
+
this.contract = new ethers.Contract(tableAddress, abi, signer);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Key derivation ─────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derive the bytes32 on-chain record key from a table name + primary key.
|
|
119
|
+
* Canonical scheme: keccak256(abi.encodePacked(tableName, id))
|
|
120
|
+
* Matches the Solidity generator and connector — all three layers are aligned.
|
|
121
|
+
*/
|
|
122
|
+
deriveKey(tableName: string, id: bigint): string {
|
|
123
|
+
return ethers.solidityPackedKeccak256(
|
|
124
|
+
['string', 'uint256'],
|
|
125
|
+
[tableName, id],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Write ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Encrypt `plaintext` and store it as a new record.
|
|
133
|
+
* The symmetric key is encrypted for the caller (owner).
|
|
134
|
+
*
|
|
135
|
+
* @param key bytes32 record key (use deriveKey or pass directly).
|
|
136
|
+
* @param plaintext Any data you want to store — string or raw bytes.
|
|
137
|
+
*/
|
|
138
|
+
async writeRaw(
|
|
139
|
+
key : string,
|
|
140
|
+
plaintext : string | Uint8Array,
|
|
141
|
+
): Promise<ethers.TransactionReceipt> {
|
|
142
|
+
const data = toBytes(plaintext);
|
|
143
|
+
const symKey = generateSymmetricKey();
|
|
144
|
+
const ciphertext = encryptData(data, symKey);
|
|
145
|
+
const encryptedKey = encryptKeyForSelf(symKey, this.keypair);
|
|
146
|
+
|
|
147
|
+
const tx = await this.c.write(key, ciphertext, encryptedKey);
|
|
148
|
+
return tx.wait();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Encrypt `plaintext` for self — returns the raw ciphertext and
|
|
153
|
+
* encrypted symmetric key bytes WITHOUT submitting any transaction.
|
|
154
|
+
*
|
|
155
|
+
* Used by Model.relatedCreate() to hand the encrypted bytes to a
|
|
156
|
+
* RelationWire contract that will call table.write() on behalf of
|
|
157
|
+
* the user within an atomic transaction.
|
|
158
|
+
*/
|
|
159
|
+
async encryptForSelf(
|
|
160
|
+
plaintext : string | Uint8Array,
|
|
161
|
+
): Promise<{ ciphertext: Uint8Array; encryptedKey: Uint8Array }> {
|
|
162
|
+
const data = toBytes(plaintext);
|
|
163
|
+
const symKey = generateSymmetricKey();
|
|
164
|
+
const ciphertext = encryptData(data, symKey);
|
|
165
|
+
const encryptedKey = encryptKeyForSelf(symKey, this.keypair);
|
|
166
|
+
return { ciphertext, encryptedKey };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Read ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Read and decrypt a record.
|
|
173
|
+
* Returns the plaintext as a UTF-8 string.
|
|
174
|
+
* Throws if the record is deleted, doesn't exist, or you lack access.
|
|
175
|
+
*/
|
|
176
|
+
async readPlaintext(key: string): Promise<string> {
|
|
177
|
+
return new TextDecoder().decode(await this.readBytes(key));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read and decrypt a record — returns raw bytes.
|
|
182
|
+
*/
|
|
183
|
+
async readBytes(key: string): Promise<Uint8Array> {
|
|
184
|
+
const raw = await this.readRaw(key);
|
|
185
|
+
const encKey = await this.getMyEncryptedKey(key);
|
|
186
|
+
const symKey = decryptKeyForSelf(encKey, this.keypair);
|
|
187
|
+
return decryptData(raw.ciphertext, symKey);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get the raw (still-encrypted) record from chain.
|
|
192
|
+
*/
|
|
193
|
+
async readRaw(key: string): Promise<RawRecord> {
|
|
194
|
+
const [ciphertext, deleted, version, updatedAt, owner] =
|
|
195
|
+
await this.c.read(key);
|
|
196
|
+
if (deleted) throw new Error(`EncryptedTableClient: record ${key} is deleted`);
|
|
197
|
+
return {
|
|
198
|
+
ciphertext: toUint8Array(ciphertext),
|
|
199
|
+
deleted,
|
|
200
|
+
version,
|
|
201
|
+
updatedAt,
|
|
202
|
+
owner,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Update ─────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Update an existing record with new plaintext.
|
|
210
|
+
* Re-encrypts with a FRESH symmetric key — old key copies are NOT
|
|
211
|
+
* automatically re-shared. Call reshareAfterUpdate() afterwards
|
|
212
|
+
* if collaborators need access to the updated version.
|
|
213
|
+
*/
|
|
214
|
+
async updateRaw(
|
|
215
|
+
key : string,
|
|
216
|
+
plaintext : string | Uint8Array,
|
|
217
|
+
): Promise<ethers.TransactionReceipt> {
|
|
218
|
+
const data = toBytes(plaintext);
|
|
219
|
+
const symKey = generateSymmetricKey();
|
|
220
|
+
const ciphertext = encryptData(data, symKey);
|
|
221
|
+
const encryptedKey = encryptKeyForSelf(symKey, this.keypair);
|
|
222
|
+
const tx = await this.c.update(key, ciphertext, encryptedKey);
|
|
223
|
+
return tx.wait();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Delete ─────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Delete a record. The contract scrubs ALL collaborator key copies
|
|
230
|
+
* on-chain. Ciphertext remains but is permanently unreadable by
|
|
231
|
+
* anyone (the symmetric key is gone).
|
|
232
|
+
*/
|
|
233
|
+
async deleteRecord(key: string): Promise<ethers.TransactionReceipt> {
|
|
234
|
+
const tx = await this.c.deleteRecord(key);
|
|
235
|
+
return tx.wait();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Sharing ────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Share a record with another user.
|
|
242
|
+
*
|
|
243
|
+
* Flow:
|
|
244
|
+
* 1. Fetch caller's encrypted key from chain
|
|
245
|
+
* 2. Decrypt to recover the symmetric key (requires caller's privkey)
|
|
246
|
+
* 3. Fetch recipient's X25519 public key from the registry
|
|
247
|
+
* 4. Re-encrypt the symmetric key for the recipient
|
|
248
|
+
* 5. Call grantAccess on-chain with the new encrypted key copy
|
|
249
|
+
*
|
|
250
|
+
* @param key bytes32 record key
|
|
251
|
+
* @param recipient Address to share with
|
|
252
|
+
* @param role Role.VIEWER or Role.EDITOR
|
|
253
|
+
* @param registry PublicKeyRegistryClient to look up recipient's pubkey
|
|
254
|
+
*/
|
|
255
|
+
async share(
|
|
256
|
+
key : string,
|
|
257
|
+
recipient : string,
|
|
258
|
+
role : Role,
|
|
259
|
+
registry : PublicKeyRegistryClient,
|
|
260
|
+
): Promise<ethers.TransactionReceipt> {
|
|
261
|
+
// 1. Get our own encrypted key copy from chain
|
|
262
|
+
const myEncKey = await this.getMyEncryptedKey(key);
|
|
263
|
+
|
|
264
|
+
// 2. Decrypt to get the plain symmetric key
|
|
265
|
+
const symKey = decryptKeyForSelf(myEncKey, this.keypair);
|
|
266
|
+
|
|
267
|
+
// 3. Look up recipient's public key from registry
|
|
268
|
+
const recipientPubKey = await registry.getPublicKey(recipient);
|
|
269
|
+
|
|
270
|
+
// 4. Re-encrypt the symmetric key for the recipient
|
|
271
|
+
const recipientEncKey = encryptKeyForRecipient(
|
|
272
|
+
symKey,
|
|
273
|
+
recipientPubKey,
|
|
274
|
+
this.keypair.privateKey,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// 5. Grant access on-chain
|
|
278
|
+
const tx = await this.c.grantAccess(
|
|
279
|
+
key,
|
|
280
|
+
recipient,
|
|
281
|
+
role,
|
|
282
|
+
recipientEncKey,
|
|
283
|
+
);
|
|
284
|
+
return tx.wait();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Revoke a user's access. Their encrypted key copy is scrubbed
|
|
289
|
+
* on-chain — they can no longer decrypt the record.
|
|
290
|
+
* (They may have decrypted and cached it locally — that is a
|
|
291
|
+
* client-side concern, not something the chain can prevent.)
|
|
292
|
+
*/
|
|
293
|
+
async revoke(
|
|
294
|
+
key : string,
|
|
295
|
+
user : string,
|
|
296
|
+
): Promise<ethers.TransactionReceipt> {
|
|
297
|
+
const tx = await this.c.revokeAccess(key, user);
|
|
298
|
+
return tx.wait();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* After updating a record (which rotates the key), re-share with
|
|
303
|
+
* all current collaborators so they can decrypt the new ciphertext.
|
|
304
|
+
*
|
|
305
|
+
* @param key bytes32 record key
|
|
306
|
+
* @param collaborators List of addresses to re-share with
|
|
307
|
+
* @param roles Role for each address (parallel array)
|
|
308
|
+
* @param registry PublicKeyRegistryClient
|
|
309
|
+
*/
|
|
310
|
+
async reshareAfterUpdate(
|
|
311
|
+
key : string,
|
|
312
|
+
collaborators : string[],
|
|
313
|
+
roles : Role[],
|
|
314
|
+
registry : PublicKeyRegistryClient,
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
if (collaborators.length !== roles.length) {
|
|
317
|
+
throw new Error('reshareAfterUpdate: collaborators and roles arrays must be same length');
|
|
318
|
+
}
|
|
319
|
+
for (let i = 0; i < collaborators.length; i++) {
|
|
320
|
+
await this.share(key, collaborators[i]!, roles[i]!, registry);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Key helpers ────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/** Fetch this caller's encrypted key blob from chain (still encrypted). */
|
|
327
|
+
async getMyEncryptedKey(key: string): Promise<Uint8Array> {
|
|
328
|
+
const hex = await this.c.getMyEncryptedKey(key) as string;
|
|
329
|
+
return ethers.getBytes(hex);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Decrypt a symmetric key blob that was encrypted by a known sender
|
|
334
|
+
* (use when you are a collaborator, not the owner — the sender's
|
|
335
|
+
* public key must be passed explicitly).
|
|
336
|
+
*/
|
|
337
|
+
decryptSharedKey(
|
|
338
|
+
encryptedKey : Uint8Array,
|
|
339
|
+
senderPublicKey : Uint8Array,
|
|
340
|
+
): Uint8Array {
|
|
341
|
+
return decryptKeyFromSender(encryptedKey, senderPublicKey, this.keypair.privateKey);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Views ──────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
async exists(key: string): Promise<boolean> {
|
|
347
|
+
return this.c.recordExists(key) as Promise<boolean>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async owner(key: string): Promise<string> {
|
|
351
|
+
return this.c.recordOwner(key) as Promise<string>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async collaboratorCount(key: string): Promise<number> {
|
|
355
|
+
return Number(await this.c.collaboratorCount(key));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** List bytes32 record keys owned by `addr` (paginated).
|
|
359
|
+
* Prefers getActiveOwnerRecords (skips deleted) when available;
|
|
360
|
+
* falls back to getOwnerRecords for older contract versions.
|
|
361
|
+
*/
|
|
362
|
+
async listOwnerRecords(
|
|
363
|
+
addr : string,
|
|
364
|
+
start : bigint = 0n,
|
|
365
|
+
limit : bigint = 50n,
|
|
366
|
+
): Promise<string[]> {
|
|
367
|
+
try {
|
|
368
|
+
return await this.c.getActiveOwnerRecords(addr, start, limit) as Promise<string[]>;
|
|
369
|
+
} catch {
|
|
370
|
+
// Older contract without getActiveOwnerRecords — fall back
|
|
371
|
+
return this.c.getOwnerRecords(addr, start, limit) as Promise<string[]>;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Total number of records written by `addr` (including deleted). */
|
|
376
|
+
async ownerRecordCount(addr: string): Promise<bigint> {
|
|
377
|
+
return this.c.ownerRecordCount(addr) as Promise<bigint>;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─────────────────────────────────────────────────────────────
|
|
382
|
+
// Helpers
|
|
383
|
+
// ─────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function toBytes(data: string | Uint8Array): Uint8Array {
|
|
386
|
+
if (typeof data === 'string') return new TextEncoder().encode(data);
|
|
387
|
+
return data;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function toUint8Array(value: string | Uint8Array): Uint8Array {
|
|
391
|
+
if (value instanceof Uint8Array) return value;
|
|
392
|
+
return ethers.getBytes(value); // hex string
|
|
393
|
+
}
|