Skip to content

Commit f90da70

Browse files
committed
Issue 62 PR 05: Event-hash integrity reconciliation
1 parent c73d0cf commit f90da70

4 files changed

Lines changed: 50 additions & 126 deletions

File tree

SPEC.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -995,7 +995,7 @@ Token Host MUST emit, per collection:
995995
- `RecordDeleted(collectionId, recordId, actor, timestamp, isHardDelete)`
996996
- `RecordTransferred(collectionId, recordId, fromOwner, toOwner, actor, timestamp)` (only for collections with transfers enabled)
997997

998-
`dataHash` and `changedFieldsHash` SHOULD be keccak256 of ABI-encoded values to allow indexers to detect mismatches without storing full payloads in events. Token Host MAY additionally emit field-level events for frequently queried fields.
998+
`dataHash` and `changedFieldsHash` SHOULD be keccak256 of ABI-encoded values to allow indexers to detect mismatches without storing full payloads in events. In v1, `changedFieldsHash` MAY carry the post-update record hash rather than a minimal delta-only hash, so long as the generator applies the rule deterministically and documents it. Token Host MAY additionally emit field-level events for frequently queried fields.
999999

10001000
#### 7.9.1 Event indexing for narrow subscriptions (normative)
10011001

@@ -1103,6 +1103,8 @@ This design ensures:
11031103
- identical records across environments produce identical hashes,
11041104
- indexers can recompute the hash from decoded record values to detect RPC inconsistencies.
11051105

1106+
For update events, the generator MAY place this post-update `recordHash` into the `changedFieldsHash` event slot in v1. That preserves a stable event surface while still giving indexers an integrity primitive tied to the resulting stored record state.
1107+
11061108
### 7.15 Error model
11071109

11081110
Generated contracts MUST use explicit, distinguishable reverts. For gas efficiency, Token Host SHOULD prefer Solidity custom errors over string revert reasons.

packages/generator/src/solidity/generateAppSolidity.ts

Lines changed: 42 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ function queryIndexKeyExpr(fieldType: FieldType, expr: string): string {
7979
: `keccak256(abi.encode(${expr}))`;
8080
}
8181

82+
function chunkArray<T>(items: T[], size: number): T[][] {
83+
if (size <= 0) return [items.slice()];
84+
const out: T[][] = [];
85+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
86+
return out;
87+
}
88+
8289
class W {
8390
private lines: string[] = [];
8491
private indent = 0;
@@ -130,10 +137,6 @@ function bytes32FromSha256(schemaHash: string): string {
130137
return `0x${m[1]}`;
131138
}
132139

133-
function recordHashFnName(collectionName: string): string {
134-
return `_hashRecord${collectionName}`;
135-
}
136-
137140
export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolidityOptions = {}): { path: string; contents: string } {
138141
const w = new W();
139142

@@ -257,7 +260,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
257260
w.line('(bool ok, bytes memory res) = address(this).delegatecall(calls[i]);');
258261
w.block('if (!ok)', () => {
259262
// bubble up revert data (best-effort)
260-
w.block('assembly', () => {
263+
w.block('assembly ("memory-safe")', () => {
261264
w.line('revert(add(res, 32), mload(res))');
262265
});
263266
});
@@ -359,14 +362,6 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
359362
});
360363
w.line();
361364

362-
// Record hashing helper (used for event integrity without stack-too-deep risk).
363-
// Note: this uses ABI encoding of the full record tuple; callers can recompute
364-
// from decoded record values.
365-
w.block(`function ${recordHashFnName(C)}(${record} memory r) internal pure returns (bytes32)`, () => {
366-
w.line(`return keccak256(abi.encode(COLLECTION_ID_${C}, r));`);
367-
});
368-
w.line();
369-
370365
w.block(`function _init${record}(${record} storage r, uint256 id) internal`, () => {
371366
w.line('r.id = id;');
372367
w.line('r.createdAt = block.timestamp;');
@@ -380,17 +375,45 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
380375
});
381376
w.line();
382377

378+
const createFieldChunks = chunkArray(c.fields, 4);
379+
for (let i = 0; i < createFieldChunks.length; i++) {
380+
const chunk = createFieldChunks[i]!;
381+
w.block(`function _applyCreate${C}Fields_${i}(${record} storage r, ${createInputStruct} calldata input) internal`, () => {
382+
for (const f of chunk) {
383+
w.line(`r.${f.name} = input.${f.name};`);
384+
}
385+
});
386+
w.line();
387+
}
388+
383389
w.block(`function _applyCreate${C}Fields(${record} storage r, ${createInputStruct} calldata input) internal`, () => {
384-
for (const f of c.fields) {
385-
w.line(`r.${f.name} = input.${f.name};`);
390+
for (let i = 0; i < createFieldChunks.length; i++) {
391+
w.line(`_applyCreate${C}Fields_${i}(r, input);`);
386392
}
387393
});
388394
w.line();
389395

396+
const recordHashParts = [
397+
'COLLECTION_ID_' + C,
398+
'r.id',
399+
'r.createdAt',
400+
'r.createdBy',
401+
'r.owner',
402+
'r.updatedAt',
403+
'r.updatedBy',
404+
'r.isDeleted',
405+
'r.deletedAt',
406+
'r.version',
407+
...c.fields.map((f) => `r.${f.name}`)
408+
];
409+
w.block(`function _recordHash${C}(${record} storage r) internal view returns (bytes32)`, () => {
410+
w.line(`return keccak256(abi.encode(${recordHashParts.join(', ')}));`);
411+
});
412+
w.line();
413+
390414
w.block(`function _emitCreated${C}(uint256 id) internal`, () => {
391-
w.line(`${record} memory m = ${cVar}Records[id];`);
392-
w.line(`bytes32 dataHash = ${recordHashFnName(C)}(m);`);
393-
w.line(`emit RecordCreated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, dataHash);`);
415+
w.line(`${record} storage r = ${cVar}Records[id];`);
416+
w.line(`emit RecordCreated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, _recordHash${C}(r));`);
394417
});
395418
w.line();
396419

@@ -484,109 +507,6 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
484507
);
485508
w.line();
486509

487-
// Reverse reference accessor(s)
488-
if (onChainIndexing && c.relations) {
489-
for (const rel of c.relations.filter((r) => r.reverseIndex)) {
490-
w.block(
491-
`function listByRef${C}_${rel.field}(uint256 refId, uint256 offset, uint256 limit) external view returns (uint256[] memory)`,
492-
() => {
493-
w.line('if (limit > MAX_LIST_LIMIT) revert InvalidLimit();');
494-
w.line(`uint256[] storage bucket = refIndex_${C}_${rel.field}[refId];`);
495-
w.line('if (offset >= bucket.length) {');
496-
w.line(' return new uint256[](0);');
497-
w.line('}');
498-
w.line('uint256 end = offset + limit;');
499-
w.line('if (end > bucket.length) end = bucket.length;');
500-
w.line('uint256 outLen = end - offset;');
501-
w.line('uint256[] memory out = new uint256[](outLen);');
502-
w.block('for (uint256 i = 0; i < outLen; i++)', () => {
503-
w.line('out[i] = bucket[offset + i];');
504-
});
505-
w.line('return out;');
506-
}
507-
);
508-
w.line();
509-
}
510-
}
511-
if (onChainIndexing) {
512-
for (const idx of c.indexes.index) {
513-
w.line(`mapping(bytes32 => uint256[]) private index_${C}_${idx.field};`);
514-
}
515-
if (c.indexes.index.length > 0) w.line();
516-
}
517-
518-
// Reverse reference indexes (append-only)
519-
if (onChainIndexing) {
520-
const rels = relationIndexes.filter((r) => r.from.name === C && r.rel.reverseIndex);
521-
for (const r of rels) {
522-
w.line(`mapping(uint256 => uint256[]) private refIndex_${C}_${r.rel.field};`);
523-
}
524-
if (rels.length > 0) w.line();
525-
}
526-
527-
// exists / getCount
528-
w.block(`function exists${C}(uint256 id) public view returns (bool)`, () => {
529-
w.line(`${record} storage r = ${cVar}Records[id];`);
530-
w.line('if (r.createdBy == address(0)) return false;');
531-
w.line('if (r.isDeleted) return false;');
532-
w.line('return true;');
533-
});
534-
w.line();
535-
536-
w.block(`function getCount${C}(bool includeDeleted) external view returns (uint256)`, () => {
537-
w.line('if (includeDeleted) {');
538-
w.line(` return nextId${C} - 1;`);
539-
w.line('}');
540-
w.line(`return activeCount${C};`);
541-
});
542-
w.line();
543-
544-
// get
545-
w.block(`function get${C}(uint256 id, bool includeDeleted) public view returns (${record} memory)`, () => {
546-
w.line(`${record} storage r = ${cVar}Records[id];`);
547-
w.line('if (r.createdBy == address(0)) revert RecordNotFound();');
548-
w.line('if (!includeDeleted && r.isDeleted) revert RecordIsDeleted();');
549-
w.line('return r;');
550-
});
551-
w.line();
552-
553-
w.block(`function get${C}(uint256 id) external view returns (${record} memory)`, () => {
554-
w.line(`return get${C}(id, false);`);
555-
});
556-
w.line();
557-
558-
// listIds
559-
w.block(
560-
`function listIds${C}(uint256 cursorIdExclusive, uint256 limit, bool includeDeleted) external view returns (uint256[] memory)`,
561-
() => {
562-
w.line('if (limit > MAX_LIST_LIMIT) revert InvalidLimit();');
563-
w.line(`uint256 cursor = cursorIdExclusive;`);
564-
w.line(`uint256 nextId = nextId${C};`);
565-
w.line('if (cursor == 0 || cursor > nextId) {');
566-
w.line(' cursor = nextId;');
567-
w.line('}');
568-
w.line('uint256[] memory tmp = new uint256[](limit);');
569-
w.line('uint256 found = 0;');
570-
w.line('uint256 steps = 0;');
571-
w.line('uint256 id = cursor;');
572-
w.block('while (id > 1 && found < limit && steps < MAX_SCAN_STEPS)', () => {
573-
w.line('id--;');
574-
w.line('steps++;');
575-
w.line(`${record} storage r = ${cVar}Records[id];`);
576-
w.line('if (r.createdBy == address(0)) { continue; }');
577-
w.line('if (!includeDeleted && r.isDeleted) { continue; }');
578-
w.line('tmp[found] = id;');
579-
w.line('found++;');
580-
});
581-
w.line('uint256[] memory out = new uint256[](found);');
582-
w.block('for (uint256 i = 0; i < found; i++)', () => {
583-
w.line('out[i] = tmp[i];');
584-
});
585-
w.line('return out;');
586-
}
587-
);
588-
w.line();
589-
590510
// Reverse reference accessor(s)
591511
if (onChainIndexing && c.relations) {
592512
for (const rel of c.relations.filter((r) => r.reverseIndex)) {
@@ -805,9 +725,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
805725
w.line('r.updatedAt = block.timestamp;');
806726
w.line('r.updatedBy = _msgSender();');
807727
w.line('r.version += 1;');
808-
w.line(`${record} memory m = r;`);
809-
w.line(`bytes32 changedFieldsHash = ${recordHashFnName(C)}(m);`);
810-
w.line(`emit RecordUpdated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, changedFieldsHash);`);
728+
w.line(`emit RecordUpdated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, _recordHash${C}(r));`);
811729
});
812730
w.line();
813731
}

test/testCliBenchmarkRegistryBuild.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('th build (benchmark registry schema)', function () {
2626
const appSol = fs.readFileSync(path.join(outDir, 'contracts', 'App.sol'), 'utf-8');
2727
expect(appSol).to.include('struct CreateBenchmarkRunInput');
2828
expect(appSol).to.include('function createBenchmarkRun(CreateBenchmarkRunInput calldata input)');
29+
expect(appSol).to.include('function _recordHashBenchmarkRun');
2930

3031
const compiled = JSON.parse(fs.readFileSync(path.join(outDir, 'compiled', 'App.json'), 'utf-8'));
3132
expect(String(compiled.compilerProfile || '')).to.match(/auto|large-app/);

test/testCrudGenerator.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ function compileSolidity(sourcePath, contents, contractName) {
3232
}
3333

3434
describe('Spec-aligned CRUD generator', function () {
35+
this.timeout(15000);
36+
3537
it('generates Solidity that compiles (job-board example)', function () {
3638
const __filename = fileURLToPath(import.meta.url);
3739
const __dirname = path.dirname(__filename);
@@ -52,9 +54,10 @@ describe('Spec-aligned CRUD generator', function () {
5254
expect(appSol.contents).to.include('event RecordCreated');
5355
expect(appSol.contents).to.include('function createCandidate');
5456
expect(appSol.contents).to.include('function createJobPosting');
57+
expect(appSol.contents).to.include('function _recordHashCandidate');
58+
expect(appSol.contents).to.include('function _recordHashJobPosting');
5559

5660
const { errors } = compileSolidity(appSol.path, appSol.contents, 'App');
5761
expect(errors.map((e) => e.formattedMessage || e.message).join('\n')).to.equal('');
5862
});
5963
});
60-

0 commit comments

Comments
 (0)