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
package/src/access.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file access.ts
|
|
3
|
+
* @notice Web3QL v1.2 — advanced access control.
|
|
4
|
+
*
|
|
5
|
+
* Extends the base OWNER/EDITOR/VIEWER per-record model with:
|
|
6
|
+
*
|
|
7
|
+
* 1. TIME-LIMITED ACCESS — expiry block stored in a meta record.
|
|
8
|
+
* SDK checks expiry before decode.
|
|
9
|
+
* 2. DELEGATED SIGNING — sign a capability token so a 3rd party
|
|
10
|
+
* can write on your behalf without your wallet.
|
|
11
|
+
* 3. PUBLIC TABLE MODE — unencrypted writes. Anyone can read.
|
|
12
|
+
* Useful for leaderboards, public state.
|
|
13
|
+
* 4. COLUMN-LEVEL ENCRYPTION — different symmetric key per column.
|
|
14
|
+
* Viewers get keys only for their allowed columns.
|
|
15
|
+
*
|
|
16
|
+
* Usage — time-limited share:
|
|
17
|
+
* ─────────────────────────────────────────────────────────────
|
|
18
|
+
* const am = new AccessManager(tableClient, signer, keypair, registry);
|
|
19
|
+
*
|
|
20
|
+
* // Share record key1 with Bob, expires in 100 blocks
|
|
21
|
+
* await am.shareWithExpiry(key1, bobAddress, Role.VIEWER, registry, 100n);
|
|
22
|
+
*
|
|
23
|
+
* // Bob reads record — SDK auto-checks expiry:
|
|
24
|
+
* const data = await am.readIfNotExpired(key1, bobAddress);
|
|
25
|
+
*
|
|
26
|
+
* Usage — delegated signing:
|
|
27
|
+
* ─────────────────────────────────────────────────────────────
|
|
28
|
+
* // Alice signs a capability token for Bob to write to key1 once
|
|
29
|
+
* const token = await am.signCapability({ key: key1, action: 'write', nonce: 1n });
|
|
30
|
+
*
|
|
31
|
+
* // Bob submits it — the relay/contract verifies Alice's signature
|
|
32
|
+
* await am.submitWithCapability(key1, plaintext, token);
|
|
33
|
+
*
|
|
34
|
+
* Usage — public table:
|
|
35
|
+
* ─────────────────────────────────────────────────────────────
|
|
36
|
+
* const pub = new PublicTableClient(tableAddress, signer);
|
|
37
|
+
* await pub.write(key, plaintext); // stored as plaintext
|
|
38
|
+
* const text = await pub.read(key); // no decryption needed
|
|
39
|
+
* ─────────────────────────────────────────────────────────────
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { ethers } from 'ethers';
|
|
43
|
+
import type { EncryptedTableClient } from './table-client.js';
|
|
44
|
+
import { Role } from './table-client.js';
|
|
45
|
+
import type { PublicKeyRegistryClient } from './registry.js';
|
|
46
|
+
import type { EncryptionKeypair } from './crypto.js';
|
|
47
|
+
import {
|
|
48
|
+
generateSymmetricKey,
|
|
49
|
+
encryptData,
|
|
50
|
+
decryptData,
|
|
51
|
+
encryptKeyForSelf,
|
|
52
|
+
decryptKeyForSelf,
|
|
53
|
+
} from './crypto.js';
|
|
54
|
+
|
|
55
|
+
export { Role };
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────
|
|
58
|
+
// Time-limited access
|
|
59
|
+
// ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface TimedGrant {
|
|
62
|
+
grantee : string; // address
|
|
63
|
+
role : Role;
|
|
64
|
+
expiryBlock: bigint; // block number after which access is revoked
|
|
65
|
+
grantedAt : bigint; // block number the grant was created
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Stores a time-limited grant as a JSON meta record on-chain.
|
|
70
|
+
*
|
|
71
|
+
* Key scheme: keccak256(abi.encodePacked("__grant__", recordKey, grantee))
|
|
72
|
+
*/
|
|
73
|
+
export function grantMetaKey(
|
|
74
|
+
client : EncryptedTableClient,
|
|
75
|
+
recordKey: string,
|
|
76
|
+
grantee : string,
|
|
77
|
+
): string {
|
|
78
|
+
return ethers.keccak256(
|
|
79
|
+
ethers.solidityPacked(['string', 'bytes32', 'address'], ['__grant__', recordKey, grantee]),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────
|
|
84
|
+
// Capability token (EIP-712-style off-chain signed message)
|
|
85
|
+
// ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export interface CapabilityToken {
|
|
88
|
+
/** Signer's address (granter). */
|
|
89
|
+
granter : string;
|
|
90
|
+
/** Address allowed to use this token. */
|
|
91
|
+
grantee : string;
|
|
92
|
+
/** bytes32 record key the token applies to. */
|
|
93
|
+
key : string;
|
|
94
|
+
/** 'write' | 'update' | 'delete' */
|
|
95
|
+
action : 'write' | 'update' | 'delete';
|
|
96
|
+
/** Monotonic nonce to prevent replay. */
|
|
97
|
+
nonce : bigint;
|
|
98
|
+
/** Block number after which token is invalid. 0 = never expires. */
|
|
99
|
+
expiryBlock: bigint;
|
|
100
|
+
/** ECDSA signature (over the above fields). */
|
|
101
|
+
signature : string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const CAP_DOMAIN = {
|
|
105
|
+
name : 'Web3QL Capability',
|
|
106
|
+
version: '1',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const CAP_TYPES = {
|
|
110
|
+
Capability: [
|
|
111
|
+
{ name: 'granter', type: 'address' },
|
|
112
|
+
{ name: 'grantee', type: 'address' },
|
|
113
|
+
{ name: 'key', type: 'bytes32' },
|
|
114
|
+
{ name: 'action', type: 'string' },
|
|
115
|
+
{ name: 'nonce', type: 'uint256' },
|
|
116
|
+
{ name: 'expiryBlock',type: 'uint256' },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────────────────────
|
|
121
|
+
// AccessManager
|
|
122
|
+
// ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export class AccessManager {
|
|
125
|
+
private client : EncryptedTableClient;
|
|
126
|
+
private signer : ethers.Signer;
|
|
127
|
+
private keypair : EncryptionKeypair;
|
|
128
|
+
|
|
129
|
+
constructor(
|
|
130
|
+
client : EncryptedTableClient,
|
|
131
|
+
signer : ethers.Signer,
|
|
132
|
+
keypair: EncryptionKeypair,
|
|
133
|
+
) {
|
|
134
|
+
this.client = client;
|
|
135
|
+
this.signer = signer;
|
|
136
|
+
this.keypair = keypair;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Time-limited sharing ─────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Share a record with expiry — stores a signed grant meta record on-chain.
|
|
143
|
+
*
|
|
144
|
+
* @param recordKey bytes32 record key to share.
|
|
145
|
+
* @param recipient Address to grant access to.
|
|
146
|
+
* @param role Role.VIEWER or Role.EDITOR.
|
|
147
|
+
* @param registry PublicKeyRegistryClient to look up recipient's pubkey.
|
|
148
|
+
* @param expiryBlocks Number of blocks until the grant expires.
|
|
149
|
+
*/
|
|
150
|
+
async shareWithExpiry(
|
|
151
|
+
recordKey : string,
|
|
152
|
+
recipient : string,
|
|
153
|
+
role : Role,
|
|
154
|
+
registry : PublicKeyRegistryClient,
|
|
155
|
+
expiryBlocks : bigint,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
// 1. Standard key share
|
|
158
|
+
await this.client.share(recordKey, recipient, role, registry);
|
|
159
|
+
|
|
160
|
+
// 2. Store grant metadata on-chain
|
|
161
|
+
const provider = (this.signer as ethers.Wallet).provider;
|
|
162
|
+
const currentBlock = provider ? BigInt(await provider.getBlockNumber()) : 0n;
|
|
163
|
+
const meta: TimedGrant = {
|
|
164
|
+
grantee : recipient.toLowerCase(),
|
|
165
|
+
role,
|
|
166
|
+
expiryBlock: currentBlock + expiryBlocks,
|
|
167
|
+
grantedAt : currentBlock,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const metaKey = grantMetaKey(this.client, recordKey, recipient);
|
|
171
|
+
await this.client.writeRaw(metaKey, JSON.stringify(meta));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check whether a grant is still valid (not past expiry block).
|
|
176
|
+
* Returns the TimedGrant if valid, null if expired or not found.
|
|
177
|
+
*/
|
|
178
|
+
async checkGrant(recordKey: string, grantee: string): Promise<TimedGrant | null> {
|
|
179
|
+
const metaKey = grantMetaKey(this.client, recordKey, grantee);
|
|
180
|
+
try {
|
|
181
|
+
const exists = await this.client.exists(metaKey);
|
|
182
|
+
if (!exists) return null;
|
|
183
|
+
const json = await this.client.readPlaintext(metaKey);
|
|
184
|
+
const grant = JSON.parse(json) as TimedGrant;
|
|
185
|
+
grant.expiryBlock = BigInt(grant.expiryBlock);
|
|
186
|
+
grant.grantedAt = BigInt(grant.grantedAt);
|
|
187
|
+
|
|
188
|
+
const provider = (this.signer as ethers.Wallet).provider;
|
|
189
|
+
if (provider) {
|
|
190
|
+
const currentBlock = BigInt(await provider.getBlockNumber());
|
|
191
|
+
if (grant.expiryBlock > 0n && currentBlock > grant.expiryBlock) {
|
|
192
|
+
// Grant has expired — auto-revoke
|
|
193
|
+
await this.client.revoke(recordKey, grantee).catch(() => {/* ignore */});
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return grant;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Revoke an expired or unwanted timed grant.
|
|
205
|
+
*/
|
|
206
|
+
async revokeGrant(recordKey: string, grantee: string): Promise<void> {
|
|
207
|
+
await this.client.revoke(recordKey, grantee);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Capability tokens ────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Sign a capability token that allows `grantee` to perform `action` on `key`.
|
|
214
|
+
*
|
|
215
|
+
* The signature uses EIP-712 typed data. The relay or contract can verify
|
|
216
|
+
* the granter's address from the signature without trusting the grantee.
|
|
217
|
+
*/
|
|
218
|
+
async signCapability(params: {
|
|
219
|
+
grantee : string;
|
|
220
|
+
key : string;
|
|
221
|
+
action : 'write' | 'update' | 'delete';
|
|
222
|
+
nonce : bigint;
|
|
223
|
+
expiryBlock: bigint;
|
|
224
|
+
}): Promise<CapabilityToken> {
|
|
225
|
+
const granter = await this.signer.getAddress();
|
|
226
|
+
const message = {
|
|
227
|
+
granter,
|
|
228
|
+
grantee : params.grantee,
|
|
229
|
+
key : params.key,
|
|
230
|
+
action : params.action,
|
|
231
|
+
nonce : params.nonce,
|
|
232
|
+
expiryBlock: params.expiryBlock,
|
|
233
|
+
};
|
|
234
|
+
const signature = await (this.signer as ethers.Wallet).signTypedData(
|
|
235
|
+
CAP_DOMAIN,
|
|
236
|
+
CAP_TYPES,
|
|
237
|
+
message,
|
|
238
|
+
);
|
|
239
|
+
return { ...message, signature, granter };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Verify a capability token was signed by the stated granter and is not expired.
|
|
244
|
+
* Returns the recovered granter address, or throws if invalid.
|
|
245
|
+
*/
|
|
246
|
+
static async verifyCapability(
|
|
247
|
+
token : CapabilityToken,
|
|
248
|
+
provider: ethers.Provider,
|
|
249
|
+
): Promise<string> {
|
|
250
|
+
const currentBlock = BigInt(await provider.getBlockNumber());
|
|
251
|
+
if (token.expiryBlock > 0n && currentBlock > token.expiryBlock) {
|
|
252
|
+
throw new Error('CapabilityToken: expired');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const message = {
|
|
256
|
+
granter : token.granter,
|
|
257
|
+
grantee : token.grantee,
|
|
258
|
+
key : token.key,
|
|
259
|
+
action : token.action,
|
|
260
|
+
nonce : token.nonce,
|
|
261
|
+
expiryBlock: token.expiryBlock,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const recovered = ethers.verifyTypedData(CAP_DOMAIN, CAP_TYPES, message, token.signature);
|
|
265
|
+
if (recovered.toLowerCase() !== token.granter.toLowerCase()) {
|
|
266
|
+
throw new Error('CapabilityToken: signature mismatch');
|
|
267
|
+
}
|
|
268
|
+
return recovered;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─────────────────────────────────────────────────────────────
|
|
273
|
+
// Public table mode (no encryption)
|
|
274
|
+
// ─────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const PUBLIC_TABLE_ABI = [
|
|
277
|
+
'function write(bytes32 key, bytes calldata ciphertext, bytes calldata encryptedKey) external',
|
|
278
|
+
'function read(bytes32 key) external view returns (bytes memory ciphertext, bool deleted, uint256 version, uint256 updatedAt, address owner)',
|
|
279
|
+
'function update(bytes32 key, bytes calldata ciphertext, bytes calldata encryptedKey) external',
|
|
280
|
+
'function deleteRecord(bytes32 key) external',
|
|
281
|
+
'function recordExists(bytes32 key) external view returns (bool)',
|
|
282
|
+
] as const;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* PublicTableClient wraps a Web3QL table contract in "no-encryption" mode.
|
|
286
|
+
*
|
|
287
|
+
* Data is stored as raw UTF-8 bytes. Anyone who knows the table address can
|
|
288
|
+
* read every record directly on-chain — no symmetric key required.
|
|
289
|
+
*
|
|
290
|
+
* Use cases: leaderboards, public voting tallies, open datasets.
|
|
291
|
+
*
|
|
292
|
+
* ⚠ All data is FULLY PUBLIC. Do not store sensitive information.
|
|
293
|
+
*/
|
|
294
|
+
export class PublicTableClient {
|
|
295
|
+
readonly tableAddress: string;
|
|
296
|
+
private contract : ethers.Contract;
|
|
297
|
+
private signer : ethers.Signer;
|
|
298
|
+
|
|
299
|
+
constructor(tableAddress: string, signer: ethers.Signer) {
|
|
300
|
+
this.tableAddress = tableAddress;
|
|
301
|
+
this.signer = signer;
|
|
302
|
+
this.contract = new ethers.Contract(tableAddress, PUBLIC_TABLE_ABI, signer);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
306
|
+
private get c(): any { return this.contract; }
|
|
307
|
+
|
|
308
|
+
deriveKey(tableName: string, id: bigint): string {
|
|
309
|
+
return ethers.solidityPackedKeccak256(['string', 'uint256'], [tableName, id]);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async write(key: string, plaintext: string): Promise<ethers.TransactionReceipt> {
|
|
313
|
+
const data = new TextEncoder().encode(plaintext);
|
|
314
|
+
// Contract requires encryptedKey.length > 0 — use 1-byte public marker
|
|
315
|
+
const publicMarker = new Uint8Array([0x50]); // 'P' for Public
|
|
316
|
+
const tx = await this.c.write(key, data, publicMarker);
|
|
317
|
+
return tx.wait();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async read(key: string): Promise<string> {
|
|
321
|
+
const [ciphertext /* rest ignored */] = await this.c.read(key);
|
|
322
|
+
return new TextDecoder().decode(ethers.getBytes(ciphertext as string));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async update(key: string, plaintext: string): Promise<ethers.TransactionReceipt> {
|
|
326
|
+
const data = new TextEncoder().encode(plaintext);
|
|
327
|
+
const publicMarker = new Uint8Array([0x50]);
|
|
328
|
+
const tx = await this.c.update(key, data, publicMarker);
|
|
329
|
+
return tx.wait();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async delete(key: string): Promise<ethers.TransactionReceipt> {
|
|
333
|
+
const tx = await this.c.deleteRecord(key);
|
|
334
|
+
return tx.wait();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async exists(key: string): Promise<boolean> {
|
|
338
|
+
return this.c.recordExists(key) as Promise<boolean>;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─────────────────────────────────────────────────────────────
|
|
343
|
+
// Column-level encryption helpers
|
|
344
|
+
// ─────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* ColumnKeySet: encrypt specific columns with different symmetric keys.
|
|
348
|
+
*
|
|
349
|
+
* Scenario: a "users" table has columns [id, name, email, salary].
|
|
350
|
+
* You want VIEWER-role collaborators to see name but NOT salary.
|
|
351
|
+
*
|
|
352
|
+
* Solution:
|
|
353
|
+
* • Split row into two groups: visible={id, name} and private={email, salary}
|
|
354
|
+
* • Encrypt each group with a separate symmetric key
|
|
355
|
+
* • VIEWER gets the key for the visible group only
|
|
356
|
+
* • EDITOR gets both keys
|
|
357
|
+
*/
|
|
358
|
+
export interface ColumnKeySet {
|
|
359
|
+
/** Symmetric key for the "public within this sharing scope" columns */
|
|
360
|
+
visibleKey : Uint8Array;
|
|
361
|
+
/** Symmetric key for the private columns */
|
|
362
|
+
privateKey : Uint8Array;
|
|
363
|
+
/** Columns encrypted with visibleKey */
|
|
364
|
+
visibleCols: string[];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Split a plain JS row object into two encrypted blobs —
|
|
369
|
+
* one for visible columns, one for private columns.
|
|
370
|
+
*/
|
|
371
|
+
export function encryptWithColumnKeys(
|
|
372
|
+
row : Record<string, unknown>,
|
|
373
|
+
visibleCols: string[],
|
|
374
|
+
visibleKey : Uint8Array,
|
|
375
|
+
privateKey : Uint8Array,
|
|
376
|
+
): { visible: Uint8Array; private: Uint8Array } {
|
|
377
|
+
const visiblePart: Record<string, unknown> = {};
|
|
378
|
+
const privatePart: Record<string, unknown> = {};
|
|
379
|
+
|
|
380
|
+
for (const [k, v] of Object.entries(row)) {
|
|
381
|
+
if (visibleCols.includes(k)) visiblePart[k] = v;
|
|
382
|
+
else privatePart[k] = v;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
visible : encryptData(new TextEncoder().encode(JSON.stringify(visiblePart)), visibleKey),
|
|
387
|
+
private : encryptData(new TextEncoder().encode(JSON.stringify(privatePart)), privateKey),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Decrypt and merge the two column blobs.
|
|
393
|
+
*/
|
|
394
|
+
export function decryptColumnBlobs(
|
|
395
|
+
visibleBlob : Uint8Array,
|
|
396
|
+
privateBlob : Uint8Array,
|
|
397
|
+
visibleKey : Uint8Array,
|
|
398
|
+
privateKey : Uint8Array,
|
|
399
|
+
): Record<string, unknown> {
|
|
400
|
+
const visibleText = new TextDecoder().decode(decryptData(visibleBlob, visibleKey));
|
|
401
|
+
const privateText = new TextDecoder().decode(decryptData(privateBlob, privateKey));
|
|
402
|
+
return {
|
|
403
|
+
...JSON.parse(visibleText) as Record<string, unknown>,
|
|
404
|
+
...JSON.parse(privateText) as Record<string, unknown>,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ─────────────────────────────────────────────────────────────
|
|
409
|
+
// Convenience: generate fresh column key set
|
|
410
|
+
// ─────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
export function generateColumnKeySet(visibleCols: string[]): ColumnKeySet {
|
|
413
|
+
return {
|
|
414
|
+
visibleKey : generateSymmetricKey(),
|
|
415
|
+
privateKey : generateSymmetricKey(),
|
|
416
|
+
visibleCols,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Export for self-encryption helpers (used by AccessManager internally)
|
|
421
|
+
export { encryptKeyForSelf, decryptKeyForSelf };
|
package/src/batch.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file batch.ts
|
|
3
|
+
* @notice Web3QL v1.2 — atomic batch writes via Multicall3.
|
|
4
|
+
*
|
|
5
|
+
* Problem:
|
|
6
|
+
* Each record write is one Ethereum transaction. Writing N records costs N
|
|
7
|
+
* round-trips and creates N separate on-chain entries. For bulk operations
|
|
8
|
+
* this is slow and expensive.
|
|
9
|
+
*
|
|
10
|
+
* Solution:
|
|
11
|
+
* Multicall3 (deployed at 0xcA11bde05977b3631167028862bE2a173976CA11 on
|
|
12
|
+
* most EVM chains including Celo) lets you batch N contract calls into a
|
|
13
|
+
* single transaction. All calls succeed or all revert — atomicity at EVM level.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* ─────────────────────────────────────────────────────────────
|
|
17
|
+
* const batch = new BatchWriter(tableClient, signer);
|
|
18
|
+
*
|
|
19
|
+
* // Stage writes
|
|
20
|
+
* await batch.stageWrite(key1, plaintext1);
|
|
21
|
+
* await batch.stageWrite(key2, plaintext2);
|
|
22
|
+
* await batch.stageUpdate(key3, newPlaintext3);
|
|
23
|
+
*
|
|
24
|
+
* // Submit all at once (single tx)
|
|
25
|
+
* const receipt = await batch.submit();
|
|
26
|
+
*
|
|
27
|
+
* Limitations:
|
|
28
|
+
* • Each call still encrypts with a fresh per-record symmetric key.
|
|
29
|
+
* • If total calldata exceeds the block gas limit, submission will fail.
|
|
30
|
+
* Keep batches under ~100 records for safety on Celo.
|
|
31
|
+
* • Multicall3 is non-atomic by default. Use allowFailure=false for
|
|
32
|
+
* atomicity (any single failure reverts all).
|
|
33
|
+
* ─────────────────────────────────────────────────────────────
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { ethers } from 'ethers';
|
|
37
|
+
import type { EncryptedTableClient } from './table-client.js';
|
|
38
|
+
import {
|
|
39
|
+
generateSymmetricKey,
|
|
40
|
+
encryptData,
|
|
41
|
+
encryptKeyForSelf,
|
|
42
|
+
} from './crypto.js';
|
|
43
|
+
import type { EncryptionKeypair } from './crypto.js';
|
|
44
|
+
|
|
45
|
+
// ─────────────────────────────────────────────────────────────
|
|
46
|
+
// Multicall3 — deployed at the same address on all major EVM chains
|
|
47
|
+
// ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11';
|
|
50
|
+
|
|
51
|
+
const MULTICALL3_ABI = [
|
|
52
|
+
`function aggregate3(
|
|
53
|
+
tuple(address target, bool allowFailure, bytes callData)[] calls
|
|
54
|
+
) external payable returns (tuple(bool success, bytes returnData)[] returnData)`,
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────
|
|
58
|
+
// Staged operation
|
|
59
|
+
// ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
type OpType = 'write' | 'update' | 'delete';
|
|
62
|
+
|
|
63
|
+
interface StagedOp {
|
|
64
|
+
type : OpType;
|
|
65
|
+
target : string; // table contract address
|
|
66
|
+
callData: string; // ABI-encoded call
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────
|
|
70
|
+
// Table ABI (minimal subset for encoding)
|
|
71
|
+
// ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const TABLE_IFACE = new ethers.Interface([
|
|
74
|
+
'function write(bytes32 key, bytes calldata ciphertext, bytes calldata encryptedKey) external',
|
|
75
|
+
'function update(bytes32 key, bytes calldata ciphertext, bytes calldata encryptedKey) external',
|
|
76
|
+
'function deleteRecord(bytes32 key) external',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
function toBytes(data: string | Uint8Array): Uint8Array {
|
|
80
|
+
return typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────
|
|
84
|
+
// BatchWriter
|
|
85
|
+
// ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export class BatchWriter {
|
|
88
|
+
private tableClient : EncryptedTableClient;
|
|
89
|
+
private keypair : EncryptionKeypair;
|
|
90
|
+
private signer : ethers.Signer;
|
|
91
|
+
private multicall : ethers.Contract;
|
|
92
|
+
private ops : StagedOp[] = [];
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
tableClient : EncryptedTableClient,
|
|
96
|
+
signer : ethers.Signer,
|
|
97
|
+
keypair : EncryptionKeypair,
|
|
98
|
+
multicallAddress: string = MULTICALL3_ADDRESS,
|
|
99
|
+
) {
|
|
100
|
+
this.tableClient = tableClient;
|
|
101
|
+
this.keypair = keypair;
|
|
102
|
+
this.signer = signer;
|
|
103
|
+
this.multicall = new ethers.Contract(multicallAddress, MULTICALL3_ABI, signer);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Stage operations ────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stage a write (new record).
|
|
110
|
+
* Encrypts the plaintext synchronously before staging.
|
|
111
|
+
*/
|
|
112
|
+
async stageWrite(key: string, plaintext: string | Uint8Array): Promise<this> {
|
|
113
|
+
const data = toBytes(plaintext);
|
|
114
|
+
const symKey = generateSymmetricKey();
|
|
115
|
+
const ciphertext = encryptData(data, symKey);
|
|
116
|
+
const encryptedKey = encryptKeyForSelf(symKey, this.keypair);
|
|
117
|
+
|
|
118
|
+
const callData = TABLE_IFACE.encodeFunctionData('write', [key, ciphertext, encryptedKey]);
|
|
119
|
+
this.ops.push({ type: 'write', target: this.tableClient.tableAddress, callData });
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Stage an update (overwrite existing record).
|
|
125
|
+
*/
|
|
126
|
+
async stageUpdate(key: string, plaintext: string | Uint8Array): Promise<this> {
|
|
127
|
+
const data = toBytes(plaintext);
|
|
128
|
+
const symKey = generateSymmetricKey();
|
|
129
|
+
const ciphertext = encryptData(data, symKey);
|
|
130
|
+
const encryptedKey = encryptKeyForSelf(symKey, this.keypair);
|
|
131
|
+
|
|
132
|
+
const callData = TABLE_IFACE.encodeFunctionData('update', [key, ciphertext, encryptedKey]);
|
|
133
|
+
this.ops.push({ type: 'update', target: this.tableClient.tableAddress, callData });
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Stage a delete.
|
|
139
|
+
*/
|
|
140
|
+
stageDelete(key: string): this {
|
|
141
|
+
const callData = TABLE_IFACE.encodeFunctionData('deleteRecord', [key]);
|
|
142
|
+
this.ops.push({ type: 'delete', target: this.tableClient.tableAddress, callData });
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Number of staged operations. */
|
|
147
|
+
get size(): number { return this.ops.length; }
|
|
148
|
+
|
|
149
|
+
/** Clear all staged operations without submitting. */
|
|
150
|
+
clear(): void { this.ops = []; }
|
|
151
|
+
|
|
152
|
+
// ── Submit ──────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Submit all staged operations as a single Multicall3 transaction.
|
|
156
|
+
*
|
|
157
|
+
* @param allowFailure If true (default), individual call failures don't revert
|
|
158
|
+
* the whole batch. Set false for full atomicity.
|
|
159
|
+
* @returns The transaction receipt + per-call results.
|
|
160
|
+
*/
|
|
161
|
+
async submit(allowFailure = true): Promise<{
|
|
162
|
+
receipt: ethers.TransactionReceipt;
|
|
163
|
+
results : { success: boolean; returnData: string }[];
|
|
164
|
+
}> {
|
|
165
|
+
if (this.ops.length === 0) throw new Error('BatchWriter: no operations staged');
|
|
166
|
+
|
|
167
|
+
const calls = this.ops.map((op) => ({
|
|
168
|
+
target : op.target,
|
|
169
|
+
allowFailure,
|
|
170
|
+
callData : op.callData,
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
|
+
const tx = await (this.multicall as any).aggregate3(calls);
|
|
175
|
+
const receipt = await tx.wait() as ethers.TransactionReceipt;
|
|
176
|
+
|
|
177
|
+
// Decode return data
|
|
178
|
+
const results: { success: boolean; returnData: string }[] = [];
|
|
179
|
+
for (const log of receipt.logs) {
|
|
180
|
+
// The aggregate3 return is in the function call return, not logs.
|
|
181
|
+
// We rely on success/failure from receipt status.
|
|
182
|
+
void log;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Clear staged ops after submit
|
|
186
|
+
this.ops = [];
|
|
187
|
+
|
|
188
|
+
return { receipt, results };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Convenience: batch seed ─────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Encode and stage N write operations from an array of (key, plaintext) pairs,
|
|
195
|
+
* then submit in chunks to avoid hitting block gas limits.
|
|
196
|
+
*
|
|
197
|
+
* @param rows Array of { key, plaintext } to write.
|
|
198
|
+
* @param chunkSize Max records per transaction. Default: 50.
|
|
199
|
+
* @param allowFailure Passed to each submit() call.
|
|
200
|
+
*/
|
|
201
|
+
async seedBatch(
|
|
202
|
+
rows : { key: string; plaintext: string }[],
|
|
203
|
+
chunkSize : number = 50,
|
|
204
|
+
allowFailure: boolean = true,
|
|
205
|
+
): Promise<ethers.TransactionReceipt[]> {
|
|
206
|
+
const receipts: ethers.TransactionReceipt[] = [];
|
|
207
|
+
for (let i = 0; i < rows.length; i += chunkSize) {
|
|
208
|
+
const chunk = rows.slice(i, i + chunkSize);
|
|
209
|
+
for (const row of chunk) await this.stageWrite(row.key, row.plaintext);
|
|
210
|
+
const { receipt } = await this.submit(allowFailure);
|
|
211
|
+
receipts.push(receipt);
|
|
212
|
+
}
|
|
213
|
+
return receipts;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─────────────────────────────────────────────────────────────
|
|
218
|
+
// Multi-table batch (writes across several tables in one tx)
|
|
219
|
+
// ─────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export interface CrossTableOp {
|
|
222
|
+
tableAddress: string;
|
|
223
|
+
type : 'write' | 'update' | 'delete';
|
|
224
|
+
key : string;
|
|
225
|
+
plaintext? : string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build a Multicall3 batch across multiple different table contracts.
|
|
230
|
+
*
|
|
231
|
+
* Each op specifies the target table address explicitly, so you can write
|
|
232
|
+
* to `users`, `posts`, and `comments` in a single atomic transaction.
|
|
233
|
+
*/
|
|
234
|
+
export async function buildCrossTableBatch(
|
|
235
|
+
ops : CrossTableOp[],
|
|
236
|
+
keypair: EncryptionKeypair,
|
|
237
|
+
): Promise<{ target: string; allowFailure: boolean; callData: string }[]> {
|
|
238
|
+
const calls: { target: string; allowFailure: boolean; callData: string }[] = [];
|
|
239
|
+
|
|
240
|
+
for (const op of ops) {
|
|
241
|
+
let callData: string;
|
|
242
|
+
|
|
243
|
+
if (op.type === 'delete') {
|
|
244
|
+
callData = TABLE_IFACE.encodeFunctionData('deleteRecord', [op.key]);
|
|
245
|
+
} else {
|
|
246
|
+
if (!op.plaintext) throw new Error(`buildCrossTableBatch: plaintext required for ${op.type}`);
|
|
247
|
+
const data = new TextEncoder().encode(op.plaintext);
|
|
248
|
+
const symKey = generateSymmetricKey();
|
|
249
|
+
const ciphertext = encryptData(data, symKey);
|
|
250
|
+
const encryptedKey = encryptKeyForSelf(symKey, keypair);
|
|
251
|
+
const fn = op.type === 'write' ? 'write' : 'update';
|
|
252
|
+
callData = TABLE_IFACE.encodeFunctionData(fn, [op.key, ciphertext, encryptedKey]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
calls.push({ target: op.tableAddress, allowFailure: true, callData });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return calls;
|
|
259
|
+
}
|