Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@ Token Host MUST emit, per collection:
- `RecordDeleted(collectionId, recordId, actor, timestamp, isHardDelete)`
- `RecordTransferred(collectionId, recordId, fromOwner, toOwner, actor, timestamp)` (only for collections with transfers enabled)

`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.
`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.

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

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

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.

### 7.15 Error model

Generated contracts MUST use explicit, distinguishable reverts. For gas efficiency, Token Host SHOULD prefer Solidity custom errors over string revert reasons.
Expand Down
166 changes: 42 additions & 124 deletions packages/generator/src/solidity/generateAppSolidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ function queryIndexKeyExpr(fieldType: FieldType, expr: string): string {
: `keccak256(abi.encode(${expr}))`;
}

function chunkArray<T>(items: T[], size: number): T[][] {
if (size <= 0) return [items.slice()];
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}

class W {
private lines: string[] = [];
private indent = 0;
Expand Down Expand Up @@ -130,10 +137,6 @@ function bytes32FromSha256(schemaHash: string): string {
return `0x${m[1]}`;
}

function recordHashFnName(collectionName: string): string {
return `_hashRecord${collectionName}`;
}

export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolidityOptions = {}): { path: string; contents: string } {
const w = new W();

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

// Record hashing helper (used for event integrity without stack-too-deep risk).
// Note: this uses ABI encoding of the full record tuple; callers can recompute
// from decoded record values.
w.block(`function ${recordHashFnName(C)}(${record} memory r) internal pure returns (bytes32)`, () => {
w.line(`return keccak256(abi.encode(COLLECTION_ID_${C}, r));`);
});
w.line();

w.block(`function _init${record}(${record} storage r, uint256 id) internal`, () => {
w.line('r.id = id;');
w.line('r.createdAt = block.timestamp;');
Expand All @@ -380,17 +375,45 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
});
w.line();

const createFieldChunks = chunkArray(c.fields, 4);
for (let i = 0; i < createFieldChunks.length; i++) {
const chunk = createFieldChunks[i]!;
w.block(`function _applyCreate${C}Fields_${i}(${record} storage r, ${createInputStruct} calldata input) internal`, () => {
for (const f of chunk) {
w.line(`r.${f.name} = input.${f.name};`);
}
});
w.line();
}

w.block(`function _applyCreate${C}Fields(${record} storage r, ${createInputStruct} calldata input) internal`, () => {
for (const f of c.fields) {
w.line(`r.${f.name} = input.${f.name};`);
for (let i = 0; i < createFieldChunks.length; i++) {
w.line(`_applyCreate${C}Fields_${i}(r, input);`);
}
});
w.line();

const recordHashParts = [
'COLLECTION_ID_' + C,
'r.id',
'r.createdAt',
'r.createdBy',
'r.owner',
'r.updatedAt',
'r.updatedBy',
'r.isDeleted',
'r.deletedAt',
'r.version',
...c.fields.map((f) => `r.${f.name}`)
];
w.block(`function _recordHash${C}(${record} storage r) internal view returns (bytes32)`, () => {
w.line(`return keccak256(abi.encode(${recordHashParts.join(', ')}));`);
});
w.line();

w.block(`function _emitCreated${C}(uint256 id) internal`, () => {
w.line(`${record} memory m = ${cVar}Records[id];`);
w.line(`bytes32 dataHash = ${recordHashFnName(C)}(m);`);
w.line(`emit RecordCreated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, dataHash);`);
w.line(`${record} storage r = ${cVar}Records[id];`);
w.line(`emit RecordCreated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, _recordHash${C}(r));`);
});
w.line();

Expand Down Expand Up @@ -484,109 +507,6 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
);
w.line();

// Reverse reference accessor(s)
if (onChainIndexing && c.relations) {
for (const rel of c.relations.filter((r) => r.reverseIndex)) {
w.block(
`function listByRef${C}_${rel.field}(uint256 refId, uint256 offset, uint256 limit) external view returns (uint256[] memory)`,
() => {
w.line('if (limit > MAX_LIST_LIMIT) revert InvalidLimit();');
w.line(`uint256[] storage bucket = refIndex_${C}_${rel.field}[refId];`);
w.line('if (offset >= bucket.length) {');
w.line(' return new uint256[](0);');
w.line('}');
w.line('uint256 end = offset + limit;');
w.line('if (end > bucket.length) end = bucket.length;');
w.line('uint256 outLen = end - offset;');
w.line('uint256[] memory out = new uint256[](outLen);');
w.block('for (uint256 i = 0; i < outLen; i++)', () => {
w.line('out[i] = bucket[offset + i];');
});
w.line('return out;');
}
);
w.line();
}
}
if (onChainIndexing) {
for (const idx of c.indexes.index) {
w.line(`mapping(bytes32 => uint256[]) private index_${C}_${idx.field};`);
}
if (c.indexes.index.length > 0) w.line();
}

// Reverse reference indexes (append-only)
if (onChainIndexing) {
const rels = relationIndexes.filter((r) => r.from.name === C && r.rel.reverseIndex);
for (const r of rels) {
w.line(`mapping(uint256 => uint256[]) private refIndex_${C}_${r.rel.field};`);
}
if (rels.length > 0) w.line();
}

// exists / getCount
w.block(`function exists${C}(uint256 id) public view returns (bool)`, () => {
w.line(`${record} storage r = ${cVar}Records[id];`);
w.line('if (r.createdBy == address(0)) return false;');
w.line('if (r.isDeleted) return false;');
w.line('return true;');
});
w.line();

w.block(`function getCount${C}(bool includeDeleted) external view returns (uint256)`, () => {
w.line('if (includeDeleted) {');
w.line(` return nextId${C} - 1;`);
w.line('}');
w.line(`return activeCount${C};`);
});
w.line();

// get
w.block(`function get${C}(uint256 id, bool includeDeleted) public view returns (${record} memory)`, () => {
w.line(`${record} storage r = ${cVar}Records[id];`);
w.line('if (r.createdBy == address(0)) revert RecordNotFound();');
w.line('if (!includeDeleted && r.isDeleted) revert RecordIsDeleted();');
w.line('return r;');
});
w.line();

w.block(`function get${C}(uint256 id) external view returns (${record} memory)`, () => {
w.line(`return get${C}(id, false);`);
});
w.line();

// listIds
w.block(
`function listIds${C}(uint256 cursorIdExclusive, uint256 limit, bool includeDeleted) external view returns (uint256[] memory)`,
() => {
w.line('if (limit > MAX_LIST_LIMIT) revert InvalidLimit();');
w.line(`uint256 cursor = cursorIdExclusive;`);
w.line(`uint256 nextId = nextId${C};`);
w.line('if (cursor == 0 || cursor > nextId) {');
w.line(' cursor = nextId;');
w.line('}');
w.line('uint256[] memory tmp = new uint256[](limit);');
w.line('uint256 found = 0;');
w.line('uint256 steps = 0;');
w.line('uint256 id = cursor;');
w.block('while (id > 1 && found < limit && steps < MAX_SCAN_STEPS)', () => {
w.line('id--;');
w.line('steps++;');
w.line(`${record} storage r = ${cVar}Records[id];`);
w.line('if (r.createdBy == address(0)) { continue; }');
w.line('if (!includeDeleted && r.isDeleted) { continue; }');
w.line('tmp[found] = id;');
w.line('found++;');
});
w.line('uint256[] memory out = new uint256[](found);');
w.block('for (uint256 i = 0; i < found; i++)', () => {
w.line('out[i] = tmp[i];');
});
w.line('return out;');
}
);
w.line();

// Reverse reference accessor(s)
if (onChainIndexing && c.relations) {
for (const rel of c.relations.filter((r) => r.reverseIndex)) {
Expand Down Expand Up @@ -805,9 +725,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
w.line('r.updatedAt = block.timestamp;');
w.line('r.updatedBy = _msgSender();');
w.line('r.version += 1;');
w.line(`${record} memory m = r;`);
w.line(`bytes32 changedFieldsHash = ${recordHashFnName(C)}(m);`);
w.line(`emit RecordUpdated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, changedFieldsHash);`);
w.line(`emit RecordUpdated(COLLECTION_ID_${C}, id, _msgSender(), block.timestamp, _recordHash${C}(r));`);
});
w.line();
}
Expand Down
1 change: 1 addition & 0 deletions test/testCliBenchmarkRegistryBuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('th build (benchmark registry schema)', function () {
const appSol = fs.readFileSync(path.join(outDir, 'contracts', 'App.sol'), 'utf-8');
expect(appSol).to.include('struct CreateBenchmarkRunInput');
expect(appSol).to.include('function createBenchmarkRun(CreateBenchmarkRunInput calldata input)');
expect(appSol).to.include('function _recordHashBenchmarkRun');

const compiled = JSON.parse(fs.readFileSync(path.join(outDir, 'compiled', 'App.json'), 'utf-8'));
expect(String(compiled.compilerProfile || '')).to.match(/auto|large-app/);
Expand Down
5 changes: 4 additions & 1 deletion test/testCrudGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ function compileSolidity(sourcePath, contents, contractName) {
}

describe('Spec-aligned CRUD generator', function () {
this.timeout(15000);

it('generates Solidity that compiles (job-board example)', function () {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand All @@ -52,9 +54,10 @@ describe('Spec-aligned CRUD generator', function () {
expect(appSol.contents).to.include('event RecordCreated');
expect(appSol.contents).to.include('function createCandidate');
expect(appSol.contents).to.include('function createJobPosting');
expect(appSol.contents).to.include('function _recordHashCandidate');
expect(appSol.contents).to.include('function _recordHashJobPosting');

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

Loading