@cofhe/sdk 0.7.1 → 1.0.0-alpha.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 (123) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/acps/acp.ts +35 -7
  3. package/acps/index.ts +28 -12
  4. package/acps/onchain-utils.ts +188 -49
  5. package/acps/registry.ts +231 -0
  6. package/acps/sealing.ts +9 -3
  7. package/acps/share-metadata/appKey.ts +8 -0
  8. package/acps/share-metadata/decode.ts +120 -0
  9. package/acps/share-metadata/describe.ts +198 -0
  10. package/acps/share-metadata/encode.ts +147 -0
  11. package/acps/share-metadata/erc7984.ts +26 -0
  12. package/acps/share-metadata/index.ts +9 -0
  13. package/acps/share-metadata/layout.ts +69 -0
  14. package/acps/share-metadata/presets.ts +116 -0
  15. package/acps/share-metadata/schema.ts +47 -0
  16. package/acps/share-metadata/test/codec.test.ts +140 -0
  17. package/acps/share-metadata/test/describe.test.ts +112 -0
  18. package/acps/share-metadata/test/fixtures.ts +157 -0
  19. package/acps/share-metadata/test/live.test.ts +56 -0
  20. package/acps/share-metadata/test/presets.test.ts +119 -0
  21. package/acps/share-metadata/test/verify.test.ts +144 -0
  22. package/acps/share-metadata/verify.ts +155 -0
  23. package/acps/store.ts +48 -18
  24. package/acps/test/acp.test.ts +102 -7
  25. package/acps/test/localstorage.test.ts +28 -1
  26. package/acps/test/onchainAccessStatus.test.ts +116 -0
  27. package/acps/test/registry.test.ts +144 -0
  28. package/acps/test/sealing.test.ts +23 -0
  29. package/acps/test/store.test.ts +71 -0
  30. package/acps/test/utils.test.ts +63 -0
  31. package/acps/test/validation.test.ts +0 -4
  32. package/acps/types.ts +59 -6
  33. package/acps/utils.ts +15 -2
  34. package/acps/validation.ts +3 -1
  35. package/chains/chains/arbitrum.ts +14 -0
  36. package/chains/chains/ethereum.ts +14 -0
  37. package/chains/index.ts +5 -1
  38. package/chains/test/chains.test.ts +24 -3
  39. package/core/acps.ts +164 -207
  40. package/core/client.ts +44 -13
  41. package/core/clientTypes.ts +50 -7
  42. package/core/config.ts +33 -1
  43. package/core/consts.ts +14 -2
  44. package/core/decrypt/apiError.ts +4 -1
  45. package/core/decrypt/verifyDecryptResult.ts +8 -4
  46. package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
  47. package/core/encrypt/encryptInputsBuilder.ts +6 -0
  48. package/core/encrypt/zkPackProveVerify.ts +7 -1
  49. package/core/error.ts +1 -0
  50. package/core/fetchKeys.ts +2 -2
  51. package/core/index.ts +9 -1
  52. package/core/keyStore.ts +23 -10
  53. package/core/taskManager.ts +48 -0
  54. package/core/test/acpDefaults.test.ts +108 -19
  55. package/core/test/acpStoreAccountCase.test.ts +80 -0
  56. package/core/test/acps.test.ts +128 -3
  57. package/core/test/apiError.test.ts +8 -0
  58. package/core/test/appKeyConfig.test.ts +94 -0
  59. package/core/test/client.test.ts +41 -0
  60. package/core/test/config.test.ts +24 -0
  61. package/core/test/decrypt.test.ts +11 -4
  62. package/core/test/decryptBuilders.test.ts +110 -2
  63. package/core/test/encryptInputsBuilder.test.ts +1 -1
  64. package/core/test/fetchKeys.test.ts +23 -2
  65. package/core/test/incomingShares.test.ts +84 -0
  66. package/core/test/keyStore.test.ts +18 -6
  67. package/core/test/mocksEncryptRange.test.ts +90 -0
  68. package/core/test/taskManager.test.ts +57 -0
  69. package/core/test/utils.test.ts +128 -0
  70. package/core/test/verifyDecryptResult.test.ts +51 -0
  71. package/core/types.ts +8 -0
  72. package/core/utils.ts +16 -9
  73. package/dist/acps.cjs +984 -64
  74. package/dist/acps.d.cts +589 -10
  75. package/dist/acps.d.ts +589 -10
  76. package/dist/acps.js +2 -2
  77. package/dist/chains.cjs +26 -0
  78. package/dist/chains.d.cts +45 -1
  79. package/dist/chains.d.ts +45 -1
  80. package/dist/chains.js +1 -1
  81. package/dist/chunk-6ZW76ZKB.js +228 -0
  82. package/dist/chunk-BQWHPIYU.js +1828 -0
  83. package/dist/chunk-KSOV5B4B.js +50 -0
  84. package/dist/{chunk-43USWPEH.js → chunk-OL3DZN7A.js} +159 -523
  85. package/dist/{chunk-N6IDQRRU.js → chunk-UKQAJK4N.js} +25 -1
  86. package/dist/chunk-XQ32JG3T.js +174 -0
  87. package/dist/{clientTypes-CYZjFznO.d.cts → clientTypes-C0A9ji5O.d.cts} +132 -29
  88. package/dist/{clientTypes-BN3nbzYM.d.ts → clientTypes-CcU5WvdV.d.ts} +132 -29
  89. package/dist/core.cjs +3269 -2626
  90. package/dist/core.d.cts +32 -8
  91. package/dist/core.d.ts +32 -8
  92. package/dist/core.js +5 -4
  93. package/dist/node.cjs +3074 -2436
  94. package/dist/node.d.cts +2 -2
  95. package/dist/node.d.ts +2 -2
  96. package/dist/node.js +20 -19
  97. package/dist/{acp-Wi6isVQI.d.cts → onchain-utils-D9Z33IP7.d.cts} +166 -12
  98. package/dist/{acp-Wi6isVQI.d.ts → onchain-utils-D9Z33IP7.d.ts} +166 -12
  99. package/dist/web.cjs +3204 -2493
  100. package/dist/web.d.cts +49 -3
  101. package/dist/web.d.ts +49 -3
  102. package/dist/web.js +52 -27
  103. package/dist/zkProve.worker.cjs +79 -18
  104. package/dist/zkProve.worker.js +24 -24
  105. package/node/index.ts +1 -10
  106. package/node/storage.ts +20 -9
  107. package/node/test/inherited.test.ts +5 -1
  108. package/node/test/storage-memory-fallback.test.ts +47 -0
  109. package/node/test/storage-permissions.test.ts +40 -0
  110. package/package.json +3 -1
  111. package/web/index.ts +101 -36
  112. package/web/test/countingZkProveWorker.ts +31 -0
  113. package/web/test/inherited.web.test.ts +5 -1
  114. package/web/test/mainThreadPool.web.test.ts +174 -0
  115. package/web/test/threadPoolBenchHelper.ts +134 -0
  116. package/web/test/threadPoolOff.web.test.ts +17 -0
  117. package/web/test/threadPoolOn.web.test.ts +80 -0
  118. package/web/test/workerInitRace.web.test.ts +79 -0
  119. package/web/tfheThreadPool.ts +140 -0
  120. package/web/workerManager.ts +4 -2
  121. package/web/zkProve.worker.ts +42 -26
  122. package/dist/chunk-ESMZCFJY.js +0 -12
  123. package/dist/chunk-Q7CBWGQX.js +0 -1029
@@ -0,0 +1,147 @@
1
+ import { concatHex, isHex, numberToHex, padHex, size, type Address, type Hex } from 'viem';
2
+ import { APP_KEY_TAG, ARGS_CODE, ARG_TAG, ENTRY_KIND, LAYOUT_VERSION, BYTE_WIDTH } from './layout';
3
+ import type { ShareLabel, ShareLabelReadArg, StoredValueLabel } from './schema';
4
+
5
+ /** An unsigned big-endian integer in exactly that many bytes. Throws when the value does not fit. */
6
+ const uint = (value: number | bigint, width: number): Hex => numberToHex(value, { size: width });
7
+
8
+ /** The byte-string fields of the blob: the width each must have, and how an error names it. */
9
+ const BYTES_FIELD = {
10
+ contract: { width: BYTE_WIDTH.address, name: 'a contract address' },
11
+ selector: { width: BYTE_WIDTH.selector, name: 'a selector' },
12
+ viewCallArgument: { width: BYTE_WIDTH.word, name: 'a view call argument' },
13
+ appKey: { width: BYTE_WIDTH.word, name: 'an app key' },
14
+ } as const;
15
+
16
+ /** A byte string for that field, checked to be exactly as wide as the field. */
17
+ const bytesField = (field: keyof typeof BYTES_FIELD, value: Hex): Hex => {
18
+ const { width, name } = BYTES_FIELD[field];
19
+ if (!isHex(value, { strict: true }) || size(value) !== width) {
20
+ throw new Error(`share metadata: ${name} must be ${width} bytes`);
21
+ }
22
+ return value.toLowerCase() as Hex;
23
+ };
24
+
25
+ /**
26
+ * A list of distinct items in order of first use. `insert` adds an item unless an equal one is
27
+ * already listed; `positionOf` looks an item up and throws when it was never inserted.
28
+ */
29
+ const listOf = <T>(keyOf: (item: T) => string, what: string) => {
30
+ const items: T[] = [];
31
+ const positions = new Map<string, number>();
32
+ const insert = (item: T): void => {
33
+ const key = keyOf(item);
34
+ if (positions.has(key)) return;
35
+ positions.set(key, items.length);
36
+ items.push(item);
37
+ };
38
+ const positionOf = (item: T): number => {
39
+ const position = positions.get(keyOf(item));
40
+ if (position === undefined) throw new Error(`share metadata: ${what} ${keyOf(item)} was not collected`);
41
+ return position;
42
+ };
43
+ return { items, insert, positionOf };
44
+ };
45
+
46
+ type Transaction = { block: bigint; txIndex: number };
47
+
48
+ const keyOfContract = (contract: Address): string => contract.toLowerCase();
49
+ const keyOfSelector = (selector: Hex): string => selector.toLowerCase();
50
+ const keyOfTransaction = ({ block, txIndex }: Transaction): string => `${block}:${txIndex}`;
51
+
52
+ const transactionOf = ({ block, txIndex }: Transaction): Transaction => ({ block, txIndex });
53
+
54
+ const encodeArg = (arg: ShareLabelReadArg): Hex => {
55
+ if (arg.type === 'issuer') return uint(ARG_TAG.issuer, BYTE_WIDTH.argTag);
56
+ return concatHex([uint(ARG_TAG.word, BYTE_WIDTH.argTag), bytesField('viewCallArgument', arg.value)]);
57
+ };
58
+
59
+ /** The issuer's address as a 32-byte word: the value `ARGS_CODE.issuer` and `ARG_TAG.issuer` stand for. */
60
+ const issuerWordOf = (issuer: Address | undefined): Hex | undefined =>
61
+ issuer == null ? undefined : (padHex(issuer, { size: BYTE_WIDTH.word }).toLowerCase() as Hex);
62
+
63
+ /** A literal argument that is the issuer's address becomes "the issuer": one byte, and no address in the blob. */
64
+ const asIssuer = (arg: ShareLabelReadArg, issuerWord: Hex | undefined): ShareLabelReadArg =>
65
+ arg.type === 'word' && issuerWord != null && arg.value.toLowerCase() === issuerWord ? { type: 'issuer' } : arg;
66
+
67
+ /**
68
+ * The two common shapes take one byte: no arguments, or the share issuer alone, both with the
69
+ * ctHash as the first return word. Anything else is spelled out.
70
+ */
71
+ const encodeArgs = ({ args: given, returnWord }: StoredValueLabel, issuerWord: Hex | undefined): Hex => {
72
+ const args = given.map((arg) => asIssuer(arg, issuerWord));
73
+ if (returnWord === 0 && args.length === 0) return uint(ARGS_CODE.none, BYTE_WIDTH.argsCode);
74
+ if (returnWord === 0 && args.length === 1 && args[0].type === 'issuer') {
75
+ return uint(ARGS_CODE.issuer, BYTE_WIDTH.argsCode);
76
+ }
77
+ return concatHex([
78
+ uint(ARGS_CODE.explicit, BYTE_WIDTH.argsCode),
79
+ uint(returnWord, BYTE_WIDTH.returnWord),
80
+ uint(args.length, BYTE_WIDTH.count),
81
+ ...args.map(encodeArg),
82
+ ]);
83
+ };
84
+
85
+ /**
86
+ * Encodes the labels of a share into a version 3 blob: one label per ctHash, in the order of
87
+ * `acp.handles`. The contracts, selectors and transactions the labels name are listed once each,
88
+ * in order of first use; function and event selectors share one list. Throws on a value that does
89
+ * not fit its field (more than 255 contracts, a block past uint32, ...). Given the share `issuer`, a
90
+ * stored-value argument equal to their address is stored as "the issuer" instead of the address.
91
+ * Given an `appKey` (`shareAppKey(app)`), it closes the blob, so the app can tell its shares apart.
92
+ */
93
+ export const encodeShareMetadata = (
94
+ labels: readonly ShareLabel[],
95
+ { issuer, appKey }: { issuer?: Address; appKey?: Hex } = {}
96
+ ): Hex => {
97
+ const issuerWord = issuerWordOf(issuer);
98
+
99
+ // First pass: collect what the labels name, so each list is complete before any entry points into it.
100
+ const contracts = listOf(keyOfContract, 'contract');
101
+ const selectors = listOf(keyOfSelector, 'selector');
102
+ const transactions = listOf(keyOfTransaction, 'transaction');
103
+ for (const label of labels) {
104
+ if (label.kind === 'unlabelled') continue;
105
+ contracts.insert(label.contract);
106
+ selectors.insert(label.selector);
107
+ if (label.kind === 'event') transactions.insert(transactionOf(label));
108
+ }
109
+
110
+ // Second pass: one entry per label, pointing into the lists by position.
111
+ const entries = labels.map((label): Hex => {
112
+ switch (label.kind) {
113
+ case 'stored':
114
+ return concatHex([
115
+ uint(ENTRY_KIND.stored, BYTE_WIDTH.kind),
116
+ uint(contracts.positionOf(label.contract), BYTE_WIDTH.index),
117
+ uint(selectors.positionOf(label.selector), BYTE_WIDTH.index),
118
+ uint(label.block, BYTE_WIDTH.block),
119
+ encodeArgs(label, issuerWord),
120
+ ]);
121
+ case 'event':
122
+ return concatHex([
123
+ uint(ENTRY_KIND.event, BYTE_WIDTH.kind),
124
+ uint(contracts.positionOf(label.contract), BYTE_WIDTH.index),
125
+ uint(transactions.positionOf(transactionOf(label)), BYTE_WIDTH.index),
126
+ uint(label.logIndex, BYTE_WIDTH.logIndex),
127
+ uint(selectors.positionOf(label.selector), BYTE_WIDTH.index),
128
+ ]);
129
+ case 'unlabelled':
130
+ return uint(ENTRY_KIND.unlabelled, BYTE_WIDTH.kind);
131
+ }
132
+ });
133
+
134
+ return concatHex([
135
+ uint(LAYOUT_VERSION, BYTE_WIDTH.version),
136
+ uint(contracts.items.length, BYTE_WIDTH.count),
137
+ ...contracts.items.map((contract) => bytesField('contract', contract)),
138
+ uint(selectors.items.length, BYTE_WIDTH.count),
139
+ ...selectors.items.map((selector) => bytesField('selector', selector)),
140
+ uint(transactions.items.length, BYTE_WIDTH.count),
141
+ ...transactions.items.map((tx) =>
142
+ concatHex([uint(tx.block, BYTE_WIDTH.block), uint(tx.txIndex, BYTE_WIDTH.txIndex)])
143
+ ),
144
+ ...entries,
145
+ ...(appKey == null ? [] : [uint(APP_KEY_TAG, BYTE_WIDTH.count), bytesField('appKey', appKey)]),
146
+ ]);
147
+ };
@@ -0,0 +1,26 @@
1
+ import type { Abi } from 'viem';
2
+
3
+ /**
4
+ * The parts of ERC-7984 (confidential tokens) that share labels most often point at: the balance
5
+ * read and the transfer event. One definition for the SDK's label presets and for apps, with
6
+ * `internalType` kept so `@cofhe/abi` recognises the encrypted values.
7
+ */
8
+ export const ERC7984_ABI = [
9
+ {
10
+ type: 'function',
11
+ name: 'confidentialBalanceOf',
12
+ stateMutability: 'view',
13
+ inputs: [{ name: 'account', type: 'address', internalType: 'address' }],
14
+ outputs: [{ name: '', type: 'bytes32', internalType: 'euint64' }],
15
+ },
16
+ {
17
+ type: 'event',
18
+ name: 'ConfidentialTransfer',
19
+ anonymous: false,
20
+ inputs: [
21
+ { name: 'from', type: 'address', indexed: true, internalType: 'address' },
22
+ { name: 'to', type: 'address', indexed: true, internalType: 'address' },
23
+ { name: 'amount', type: 'bytes32', indexed: true, internalType: 'euint64' },
24
+ ],
25
+ },
26
+ ] as const satisfies Abi;
@@ -0,0 +1,9 @@
1
+ export * from './schema';
2
+ export { LAYOUT_VERSION as SHARE_METADATA_VERSION } from './layout';
3
+ export * from './encode';
4
+ export * from './decode';
5
+ export * from './verify';
6
+ export * from './describe';
7
+ export * from './presets';
8
+ export * from './erc7984';
9
+ export * from './appKey';
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Byte layout of a share metadata blob, version 3. Integers are big-endian; sizes in bytes.
3
+ *
4
+ * blob = version (1)
5
+ * | contract count (1) | contracts (20 each)
6
+ * | selector count (1) | selectors (4 each) function selectors, and event
7
+ * selectors (first 4 bytes of topic0)
8
+ * | transaction count (1) | transactions (6 each: block 4, tx index 2)
9
+ * | entries, one per ctHash, in the order of acp.handles
10
+ * | app key, optional: 0x01 | key (32) keccak256 of the name of the app the
11
+ * share is for; absent when the blob ends
12
+ *
13
+ * stored value = 0x00 | contract (1) | selector (1) | block (4) | args 8 bytes or more
14
+ * event value = 0x01 | contract (1) | transaction (1) | log index (2) | selector (1) 6 bytes
15
+ * unlabelled = 0xff 1 byte
16
+ *
17
+ * args = 0x00 no arguments, return word 0
18
+ * | 0x01 one argument, the share issuer, return word 0
19
+ * | 0xff | return word (1) | count (1) | argument, count times
20
+ * argument = 0x00 | word (32) a literal ABI word
21
+ * | 0x01 the share issuer
22
+ *
23
+ * Contracts, selectors and transactions are listed once; entries refer to them by position.
24
+ * The log index is block-wide, as RPC nodes return it.
25
+ */
26
+
27
+ export const LAYOUT_VERSION = 0x03;
28
+
29
+ export const ENTRY_KIND = {
30
+ stored: 0x00,
31
+ event: 0x01,
32
+ unlabelled: 0xff,
33
+ } as const;
34
+
35
+ /** The args byte of a stored-value entry: the two common shapes in one byte, or an explicit list. */
36
+ export const ARGS_CODE = {
37
+ none: 0x00,
38
+ issuer: 0x01,
39
+ explicit: 0xff,
40
+ } as const;
41
+
42
+ export const ARG_TAG = {
43
+ word: 0x00,
44
+ issuer: 0x01,
45
+ } as const;
46
+
47
+ /** The one byte that opens the optional app key section at the end of the blob. */
48
+ export const APP_KEY_TAG = 0x01;
49
+
50
+ /** Field widths in bytes. */
51
+ export const BYTE_WIDTH = {
52
+ version: 1,
53
+ /** Length of a list, and a position in it: at most 255 of each per share. */
54
+ count: 1,
55
+ index: 1,
56
+ address: 20,
57
+ selector: 4,
58
+ /** uint32: 4.29 billion blocks. */
59
+ block: 4,
60
+ /** uint16: the position of a transaction within its block. */
61
+ txIndex: 2,
62
+ /** uint16: the block-wide position of a log (a busy block passes 255 logs). */
63
+ logIndex: 2,
64
+ kind: 1,
65
+ argsCode: 1,
66
+ returnWord: 1,
67
+ argTag: 1,
68
+ word: 32,
69
+ } as const;
@@ -0,0 +1,116 @@
1
+ import {
2
+ encodeAbiParameters,
3
+ size,
4
+ sliceHex,
5
+ toFunctionSelector,
6
+ type Abi,
7
+ type AbiFunction,
8
+ type Address,
9
+ type ContractFunctionArgs,
10
+ type ContractFunctionName,
11
+ type Log,
12
+ } from 'viem';
13
+ import { BYTE_WIDTH } from './layout';
14
+ import type { EventValueLabel, ShareLabel, ShareLabelReadArg, StoredValueLabel } from './schema';
15
+
16
+ /**
17
+ * Builders for labels. The format knows nothing about tokens: a balance is the value a view
18
+ * function returned, a transfer amount the value of one event.
19
+ */
20
+
21
+ export type ViewFunctionLabelOptions<
22
+ abi extends Abi,
23
+ functionName extends ContractFunctionName<abi, 'pure' | 'view'>,
24
+ > = {
25
+ contract: Address;
26
+ abi: abi;
27
+ functionName: functionName;
28
+ /**
29
+ * The arguments of the call, static ABI types only. One equal to the share issuer's address
30
+ * (your own, when you share your balance) is stored as "the issuer" when the labels are encoded.
31
+ */
32
+ args?: ContractFunctionArgs<abi, 'pure' | 'view', functionName>;
33
+ /** Block the value was read at (on Arbitrum: the L2 block number). */
34
+ block: bigint;
35
+ /** Which 32-byte word of the return data is the ctHash; 0 (the default) for a single return value. */
36
+ returnWord?: number;
37
+ };
38
+
39
+ const findViewFunction = (abi: Abi, name: string, arity: number): AbiFunction => {
40
+ const named = abi.filter((item): item is AbiFunction => item.type === 'function' && item.name === name);
41
+ if (named.length === 0) throw new Error(`share metadata: the ABI has no function ${name}`);
42
+ const fitting = named.filter((fn) => fn.inputs.length === arity);
43
+ if (fitting.length !== 1) {
44
+ throw new Error(
45
+ named.length === 1
46
+ ? `share metadata: ${name} takes ${named[0].inputs.length} arguments, ${arity} given`
47
+ : `share metadata: no single overload of ${name} takes ${arity} arguments`
48
+ );
49
+ }
50
+ const [fn] = fitting;
51
+ if (fn.stateMutability !== 'view' && fn.stateMutability !== 'pure') {
52
+ throw new Error(`share metadata: ${name} is not a view function`);
53
+ }
54
+ return fn;
55
+ };
56
+
57
+ const encodeReadArg = (fn: AbiFunction, index: number, value: unknown): ShareLabelReadArg => {
58
+ const input = fn.inputs[index];
59
+ const word = encodeAbiParameters([input], [value]);
60
+ if (size(word) !== BYTE_WIDTH.word) {
61
+ throw new Error(
62
+ `share metadata: argument ${index} of ${fn.name} is ${input.type}; only arguments of one static 32-byte word can be labelled`
63
+ );
64
+ }
65
+ return { type: 'word', value: word };
66
+ };
67
+
68
+ /**
69
+ * The label of a value a view function returned at a block, built from the contract's ABI: the
70
+ * function's selector, and each argument ABI-encoded to its word. Throws when the ABI has no such
71
+ * function, the arity is off, or an argument is not one static word.
72
+ */
73
+ export const viewFunctionLabel = <
74
+ const abi extends Abi,
75
+ functionName extends ContractFunctionName<abi, 'pure' | 'view'>,
76
+ >(
77
+ options: ViewFunctionLabelOptions<abi, functionName>
78
+ ): StoredValueLabel => {
79
+ const { contract, block, returnWord = 0 } = options;
80
+ const values = (options.args ?? []) as readonly unknown[];
81
+ const fn = findViewFunction(options.abi, options.functionName, values.length);
82
+ return {
83
+ kind: 'stored',
84
+ contract,
85
+ selector: toFunctionSelector(fn),
86
+ args: values.map((value, i) => encodeReadArg(fn, i, value)),
87
+ returnWord,
88
+ block,
89
+ };
90
+ };
91
+
92
+ /**
93
+ * The label of a value carried by a log, as viem returns it from `getLogs` / `getContractEvents`:
94
+ * its contract, block, transaction and log index, and its event selector. Throws for a pending log.
95
+ */
96
+ export const eventLogLabel = (
97
+ log: Pick<Log, 'address' | 'blockNumber' | 'transactionIndex' | 'logIndex' | 'topics'>
98
+ ): EventValueLabel => {
99
+ const { address, blockNumber, transactionIndex, logIndex, topics } = log;
100
+ const topic0 = topics[0];
101
+ if (blockNumber == null || transactionIndex == null || logIndex == null) {
102
+ throw new Error('share metadata: a pending log has no position to label');
103
+ }
104
+ if (topic0 == null || size(topic0) !== 32) throw new Error('share metadata: an anonymous log has no event selector');
105
+ return {
106
+ kind: 'event',
107
+ contract: address,
108
+ block: blockNumber,
109
+ txIndex: transactionIndex,
110
+ logIndex,
111
+ selector: sliceHex(topic0, 0, 4),
112
+ };
113
+ };
114
+
115
+ /** The label of a ctHash the issuer chooses not to describe. */
116
+ export const UNLABELLED: ShareLabel = { kind: 'unlabelled' };
@@ -0,0 +1,47 @@
1
+ import type { Address, Hex } from 'viem';
2
+
3
+ /**
4
+ * A label says where one ctHash of a SNAPSHOT share came from, as something the recipient can
5
+ * check against the chain. It speaks of contracts, selectors and positions in the chain only:
6
+ * it does not know what a token, a balance or a transfer is. A reader that recognises a selector
7
+ * can present the value accordingly (see `describeShareMetadata`). A share carries one label per
8
+ * ctHash, in the order of `acp.handles`, so the ctHashes themselves are never repeated.
9
+ */
10
+
11
+ /** One argument of a stored-value read: the share issuer, or a literal 32-byte ABI word. */
12
+ export type ShareLabelReadArg = { type: 'issuer' } | { type: 'word'; value: Hex };
13
+
14
+ /** A stored value: what a view function of a contract returned at a block. */
15
+ export type StoredValueLabel = {
16
+ kind: 'stored';
17
+ contract: Address;
18
+ /** 4-byte selector of the view function. */
19
+ selector: Hex;
20
+ /** Static ABI types only: each argument is one 32-byte word. */
21
+ args: ShareLabelReadArg[];
22
+ /** Which 32-byte word of the return data is the ctHash (0 for a single return value). */
23
+ returnWord: number;
24
+ /** Block the value was read at (on Arbitrum: the L2 block number). */
25
+ block: bigint;
26
+ };
27
+
28
+ /** An event value: a value carried by one event (log) that a contract emitted. */
29
+ export type EventValueLabel = {
30
+ kind: 'event';
31
+ contract: Address;
32
+ /** Block of the log (on Arbitrum: the L2 block number). */
33
+ block: bigint;
34
+ /** Index of the log's transaction within its block. */
35
+ txIndex: number;
36
+ /** Block-wide index of the log, as RPC nodes return it. */
37
+ logIndex: number;
38
+ /** First 4 bytes of the log's topic0: the event selector. */
39
+ selector: Hex;
40
+ };
41
+
42
+ /** The issuer chose not to say where the ctHash came from. */
43
+ export type UnlabelledLabel = {
44
+ kind: 'unlabelled';
45
+ };
46
+
47
+ export type ShareLabel = StoredValueLabel | EventValueLabel | UnlabelledLabel;
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { keccak256, size, stringToHex, type Hex } from 'viem';
3
+ import { encodeShareMetadata } from '../encode';
4
+ import { decodeShareAppKey, decodeShareMetadata, decodeShareMetadataFull } from '../decode';
5
+ import { shareAppKey } from '../appKey';
6
+ import type { ShareLabel } from '../schema';
7
+ import { CASES, CONFIDENTIAL_TRANSFER, FUSD, SAMPLE, WORD } from './fixtures';
8
+
9
+ const handles = (count: number): Hex[] => Array.from({ length: count }, () => WORD);
10
+
11
+ describe('share metadata codec (v3)', () => {
12
+ for (const { name, labels, blob } of CASES) {
13
+ it(`encodes ${name}`, () => {
14
+ expect(encodeShareMetadata(labels)).toBe(blob);
15
+ });
16
+
17
+ it(`decodes ${name}`, () => {
18
+ expect(decodeShareMetadata(blob as Hex, handles(labels.length))).toEqual(labels);
19
+ });
20
+ }
21
+
22
+ it('encodes the real sample into the 122-byte blob, function and event selectors in one list', () => {
23
+ const blob = encodeShareMetadata(SAMPLE.labels);
24
+ expect(blob).toBe(SAMPLE.blob);
25
+ expect(size(blob)).toBe(122);
26
+ expect(decodeShareMetadata(blob, SAMPLE.handles)).toEqual(SAMPLE.labels);
27
+ });
28
+
29
+ describe('app key', () => {
30
+ const appKey = shareAppKey('explorer.fhenix.io');
31
+
32
+ it('closes the blob with 0x01 and the 32-byte key, after the entries', () => {
33
+ const blob = encodeShareMetadata(SAMPLE.labels, { appKey });
34
+ expect(blob).toBe(SAMPLE.blob + '01' + appKey.slice(2));
35
+ expect(size(blob)).toBe(122 + 33);
36
+ });
37
+
38
+ it('decodes to the same labels, with the key alongside', () => {
39
+ const blob = encodeShareMetadata(SAMPLE.labels, { appKey });
40
+ expect(decodeShareMetadata(blob, SAMPLE.handles)).toEqual(SAMPLE.labels);
41
+ expect(decodeShareMetadataFull(blob, SAMPLE.handles)).toEqual({ labels: SAMPLE.labels, appKey });
42
+ expect(decodeShareAppKey(blob, SAMPLE.handles)).toBe(appKey);
43
+ });
44
+
45
+ it('is absent from a blob encoded without one, and from a share without metadata', () => {
46
+ expect(decodeShareAppKey(SAMPLE.blob, SAMPLE.handles)).toBeUndefined();
47
+ expect(decodeShareAppKey('0x', [])).toBeUndefined();
48
+ expect(decodeShareAppKey(undefined, [])).toBeUndefined();
49
+ });
50
+
51
+ it('fits a share with no handles: a blob of the key alone', () => {
52
+ const blob = encodeShareMetadata([], { appKey });
53
+ expect(blob).toBe('0x03' + '00' + '00' + '00' + '01' + appKey.slice(2));
54
+ expect(decodeShareMetadataFull(blob, [])).toEqual({ labels: [], appKey });
55
+ });
56
+
57
+ it('is the keccak256 of the app name', () => {
58
+ expect(appKey).toBe(keccak256(stringToHex('explorer.fhenix.io')));
59
+ expect(shareAppKey('other.app')).not.toBe(appKey);
60
+ });
61
+
62
+ it('rejects a key that is not 32 bytes, and a trailer that is not a key', () => {
63
+ expect(() => encodeShareMetadata([], { appKey: '0x1234' })).toThrow('an app key must be 32 bytes');
64
+ expect(() => decodeShareMetadata((SAMPLE.blob + '02' + appKey.slice(2)) as Hex, SAMPLE.handles)).toThrow(
65
+ 'more entries'
66
+ );
67
+ });
68
+ });
69
+
70
+ it('lists a transaction once however many labels name it', () => {
71
+ const labels: ShareLabel[] = [
72
+ { kind: 'event', contract: FUSD, block: 10n, txIndex: 1, logIndex: 2, selector: CONFIDENTIAL_TRANSFER },
73
+ { kind: 'event', contract: FUSD, block: 10n, txIndex: 1, logIndex: 5, selector: CONFIDENTIAL_TRANSFER },
74
+ ];
75
+ // version, 1 contract, 1 selector, 1 transaction, two 6-byte entries
76
+ expect(size(encodeShareMetadata(labels))).toBe(1 + 21 + 5 + 7 + 12);
77
+ });
78
+
79
+ describe('rejects', () => {
80
+ it('another version', () => {
81
+ expect(() => decodeShareMetadata('0x02000000ff', handles(1))).toThrow('unsupported version 2');
82
+ });
83
+
84
+ it('a blob that ends in the middle of a field', () => {
85
+ expect(() => decodeShareMetadata(SAMPLE.blob.slice(0, -2) as Hex, SAMPLE.handles)).toThrow('ends in the middle');
86
+ });
87
+
88
+ it('more entries than ctHashes', () => {
89
+ expect(() => decodeShareMetadata(SAMPLE.blob, SAMPLE.handles.slice(1))).toThrow('more entries');
90
+ });
91
+
92
+ it('fewer entries than ctHashes', () => {
93
+ expect(() => decodeShareMetadata(SAMPLE.blob, [...SAMPLE.handles, WORD])).toThrow('ends in the middle');
94
+ });
95
+
96
+ it('a position outside its list', () => {
97
+ // one contract, no selectors: the event entry points at selector 0
98
+ const blob = ('0x03' +
99
+ '01' +
100
+ FUSD.slice(2) +
101
+ '00' +
102
+ '01' +
103
+ '0000000a0001' +
104
+ '01' +
105
+ '00' +
106
+ '00' +
107
+ '0002' +
108
+ '00') as Hex;
109
+ expect(() => decodeShareMetadata(blob, handles(1))).toThrow('selector 0 is outside the selector list');
110
+ });
111
+
112
+ it('an unknown entry kind', () => {
113
+ expect(() => decodeShareMetadata(('0x03000000' + '07') as Hex, handles(1))).toThrow('unknown entry kind 7');
114
+ });
115
+
116
+ it('a log index past uint16 when encoding', () => {
117
+ const label: ShareLabel = {
118
+ kind: 'event',
119
+ contract: FUSD,
120
+ block: 1n,
121
+ txIndex: 0,
122
+ logIndex: 70_000,
123
+ selector: CONFIDENTIAL_TRANSFER,
124
+ };
125
+ expect(() => encodeShareMetadata([label])).toThrow();
126
+ });
127
+
128
+ it('a selector that is not 4 bytes when encoding', () => {
129
+ const label: ShareLabel = {
130
+ kind: 'stored',
131
+ contract: FUSD,
132
+ selector: '0x1234',
133
+ args: [],
134
+ returnWord: 0,
135
+ block: 1n,
136
+ };
137
+ expect(() => encodeShareMetadata([label])).toThrow('a selector must be 4 bytes');
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { padHex, parseAbi, toFunctionSelector, type Hex } from 'viem';
3
+ import { describeShareMetadata } from '../describe';
4
+ import { encodeShareMetadata } from '../encode';
5
+ import { BALANCE_OF, CONFIDENTIAL_TRANSFER, FUSD, FUSDY, ISSUER, SAMPLE } from './fixtures';
6
+
7
+ const TOKEN_ABI = parseAbi([
8
+ 'function confidentialBalanceOf(address account) view returns (bytes32 balance)',
9
+ 'function allowanceAt(address owner, uint256 epoch) view returns (bytes32 previous, bytes32 current)',
10
+ 'event ConfidentialTransfer(address indexed from, address indexed to, bytes32 indexed amount)',
11
+ ]);
12
+
13
+ /** A share by the sample issuer carrying that blob. */
14
+ const share = (metadata: Hex | undefined, handles: readonly Hex[] = SAMPLE.handles) => ({
15
+ issuer: ISSUER,
16
+ handles,
17
+ metadata,
18
+ });
19
+
20
+ describe('describeShareMetadata', () => {
21
+ it('names the functions and events of the real sample from the ABIs', () => {
22
+ const items = describeShareMetadata(share(SAMPLE.blob), { [FUSD]: TOKEN_ABI, [FUSDY]: TOKEN_ABI })!;
23
+ expect(items).toHaveLength(7);
24
+
25
+ const [balance] = items;
26
+ expect(balance).toMatchObject({
27
+ kind: 'stored',
28
+ index: 0,
29
+ handle: SAMPLE.handles[0],
30
+ contract: FUSD,
31
+ block: 314285327n,
32
+ selector: BALANCE_OF,
33
+ function: { name: 'confidentialBalanceOf', signature: 'confidentialBalanceOf(address)' },
34
+ args: [{ name: 'account', type: 'address', isIssuer: true, value: ISSUER, raw: padHex(ISSUER, { size: 32 }) }],
35
+ output: { name: 'balance', type: 'bytes32' },
36
+ });
37
+
38
+ const transfer = items[6];
39
+ expect(transfer).toMatchObject({
40
+ kind: 'event',
41
+ index: 6,
42
+ contract: FUSDY,
43
+ block: 314217333n,
44
+ txIndex: 12,
45
+ logIndex: 115,
46
+ selector: CONFIDENTIAL_TRANSFER,
47
+ event: {
48
+ name: 'ConfidentialTransfer',
49
+ signature: 'ConfidentialTransfer(address,address,bytes32)',
50
+ inputs: [
51
+ { name: 'from', type: 'address', indexed: true },
52
+ { name: 'to', type: 'address', indexed: true },
53
+ { name: 'amount', type: 'bytes32', indexed: true },
54
+ ],
55
+ },
56
+ });
57
+ });
58
+
59
+ it('matches ABI addresses case-insensitively', () => {
60
+ const [item] = describeShareMetadata(share(SAMPLE.blob), {
61
+ [FUSD.toLowerCase() as `0x${string}`]: TOKEN_ABI,
62
+ })!;
63
+ expect(item.kind === 'stored' && item.function?.name).toBe('confidentialBalanceOf');
64
+ });
65
+
66
+ it('decodes literal arguments with their ABI types, and names a later return word', () => {
67
+ const blob = encodeShareMetadata([
68
+ {
69
+ kind: 'stored',
70
+ contract: FUSD,
71
+ selector: toFunctionSelector('allowanceAt(address,uint256)'),
72
+ args: [{ type: 'issuer' }, { type: 'word', value: padHex('0x07', { size: 32 }) }],
73
+ returnWord: 1,
74
+ block: 1n,
75
+ },
76
+ ]);
77
+ const [item] = describeShareMetadata(share(blob, [SAMPLE.handles[0]]), { [FUSD]: TOKEN_ABI })!;
78
+ expect(item).toMatchObject({
79
+ kind: 'stored',
80
+ function: { name: 'allowanceAt' },
81
+ args: [
82
+ { name: 'owner', type: 'address', isIssuer: true, value: ISSUER },
83
+ { name: 'epoch', type: 'uint256', isIssuer: false, value: 7n },
84
+ ],
85
+ output: { name: 'current', type: 'bytes32' },
86
+ });
87
+ });
88
+
89
+ it('leaves an item raw without an ABI, and never throws for it', () => {
90
+ const items = describeShareMetadata(share(SAMPLE.blob))!;
91
+ expect(items[0]).toMatchObject({ kind: 'stored', function: null, output: null, selector: BALANCE_OF });
92
+ // the issuer comes from the share, ABI or not
93
+ expect(items[0].kind === 'stored' && items[0].args[0]).toMatchObject({
94
+ isIssuer: true,
95
+ raw: padHex(ISSUER, { size: 32 }),
96
+ value: ISSUER,
97
+ });
98
+ expect(items[2]).toMatchObject({ kind: 'event', event: null, selector: CONFIDENTIAL_TRANSFER, logIndex: 30 });
99
+ });
100
+
101
+ it('marks an unlabelled value', () => {
102
+ const blob = encodeShareMetadata([{ kind: 'unlabelled' }]);
103
+ expect(describeShareMetadata(share(blob, [SAMPLE.handles[0]]))).toEqual([
104
+ { kind: 'unlabelled', index: 0, handle: SAMPLE.handles[0], label: { kind: 'unlabelled' } },
105
+ ]);
106
+ });
107
+
108
+ it('is null for a share without labels', () => {
109
+ expect(describeShareMetadata(share('0x'))).toBeNull();
110
+ expect(describeShareMetadata(share(undefined))).toBeNull();
111
+ });
112
+ });