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.
Files changed (86) hide show
  1. package/README.md +66 -0
  2. package/contracts/PublicKeyRegistry.sol +87 -0
  3. package/dist/src/access.d.ts +176 -0
  4. package/dist/src/access.d.ts.map +1 -0
  5. package/dist/src/access.js +283 -0
  6. package/dist/src/access.js.map +1 -0
  7. package/dist/src/batch.d.ts +107 -0
  8. package/dist/src/batch.d.ts.map +1 -0
  9. package/dist/src/batch.js +188 -0
  10. package/dist/src/batch.js.map +1 -0
  11. package/dist/src/cli.d.ts +40 -0
  12. package/dist/src/cli.d.ts.map +1 -0
  13. package/dist/src/cli.js +361 -0
  14. package/dist/src/cli.js.map +1 -0
  15. package/dist/src/constraints.d.ts +126 -0
  16. package/dist/src/constraints.d.ts.map +1 -0
  17. package/dist/src/constraints.js +192 -0
  18. package/dist/src/constraints.js.map +1 -0
  19. package/dist/src/crypto.d.ts +118 -0
  20. package/dist/src/crypto.d.ts.map +1 -0
  21. package/dist/src/crypto.js +192 -0
  22. package/dist/src/crypto.js.map +1 -0
  23. package/dist/src/factory-client.d.ts +106 -0
  24. package/dist/src/factory-client.d.ts.map +1 -0
  25. package/dist/src/factory-client.js +202 -0
  26. package/dist/src/factory-client.js.map +1 -0
  27. package/dist/src/index-cache.d.ts +156 -0
  28. package/dist/src/index-cache.d.ts.map +1 -0
  29. package/dist/src/index-cache.js +265 -0
  30. package/dist/src/index-cache.js.map +1 -0
  31. package/dist/src/index.d.ts +60 -0
  32. package/dist/src/index.d.ts.map +1 -0
  33. package/dist/src/index.js +60 -0
  34. package/dist/src/index.js.map +1 -0
  35. package/dist/src/migrations.d.ts +114 -0
  36. package/dist/src/migrations.d.ts.map +1 -0
  37. package/dist/src/migrations.js +173 -0
  38. package/dist/src/migrations.js.map +1 -0
  39. package/dist/src/model.d.ts +198 -0
  40. package/dist/src/model.d.ts.map +1 -0
  41. package/dist/src/model.js +379 -0
  42. package/dist/src/model.js.map +1 -0
  43. package/dist/src/query.d.ts +155 -0
  44. package/dist/src/query.d.ts.map +1 -0
  45. package/dist/src/query.js +386 -0
  46. package/dist/src/query.js.map +1 -0
  47. package/dist/src/registry.d.ts +45 -0
  48. package/dist/src/registry.d.ts.map +1 -0
  49. package/dist/src/registry.js +80 -0
  50. package/dist/src/registry.js.map +1 -0
  51. package/dist/src/schema-manager.d.ts +109 -0
  52. package/dist/src/schema-manager.d.ts.map +1 -0
  53. package/dist/src/schema-manager.js +259 -0
  54. package/dist/src/schema-manager.js.map +1 -0
  55. package/dist/src/table-client.d.ts +156 -0
  56. package/dist/src/table-client.d.ts.map +1 -0
  57. package/dist/src/table-client.js +292 -0
  58. package/dist/src/table-client.js.map +1 -0
  59. package/dist/src/typed-table.d.ts +159 -0
  60. package/dist/src/typed-table.d.ts.map +1 -0
  61. package/dist/src/typed-table.js +246 -0
  62. package/dist/src/typed-table.js.map +1 -0
  63. package/dist/src/types.d.ts +48 -0
  64. package/dist/src/types.d.ts.map +1 -0
  65. package/dist/src/types.js +222 -0
  66. package/dist/src/types.js.map +1 -0
  67. package/keyManager.js +337 -0
  68. package/package.json +38 -0
  69. package/src/access.ts +421 -0
  70. package/src/batch.ts +259 -0
  71. package/src/cli.ts +349 -0
  72. package/src/constraints.ts +283 -0
  73. package/src/crypto.ts +239 -0
  74. package/src/factory-client.ts +237 -0
  75. package/src/index-cache.ts +351 -0
  76. package/src/index.ts +171 -0
  77. package/src/migrations.ts +215 -0
  78. package/src/model.ts +538 -0
  79. package/src/query.ts +508 -0
  80. package/src/registry.ts +100 -0
  81. package/src/schema-manager.ts +301 -0
  82. package/src/table-client.ts +393 -0
  83. package/src/typed-table.ts +340 -0
  84. package/src/types.ts +284 -0
  85. package/tsconfig.json +22 -0
  86. 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
+ }