Skip to content

Commit c73d0cf

Browse files
committed
Issue 62 PR 04: Bounded-cost controls and chain-aware limits
1 parent f27a764 commit c73d0cf

8 files changed

Lines changed: 226 additions & 18 deletions

File tree

SPEC.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Status: Draft (spec-driven design)
44
Owner: Token Host
55
Domain: `tokenhost.com`
6-
Last updated: 2026-02-05
6+
Last updated: 2026-03-23
77
Scope: Production system. All existing repos are legacy prototypes and are not binding.
88

99
This document defines the intended production design of the Token Host platform: a managed schema-to-dapp builder that generates and deploys smart contracts, generates a hosted UI, optionally provisions indexing, and publishes apps under a dedicated hosted-app origin (default `*.apps.tokenhost.com`, plus optional custom domains).
@@ -857,7 +857,7 @@ Implementation note: list methods MUST cap `limit` to a safe maximum (configurab
857857

858858
Recommended defaults (non-normative):
859859
- If chain config provides `limits.lists.maxLimit` and/or `limits.lists.maxScanSteps`, the generator SHOULD use those values (or stricter).
860-
- Otherwise, a safe default is `maxLimit=50` and `maxScanSteps = min(1000, maxLimit * 20)`.
860+
- Otherwise, a safe default is `maxLimit=50` and `maxScanSteps=1000`.
861861
- The UI SHOULD handle “short pages” (fewer than `limit` results) gracefully and SHOULD allow loading more using the next cursor (the smallest returned ID).
862862

863863
### 7.6 Update semantics
@@ -931,6 +931,25 @@ For each indexed field `f`:
931931
- If a record’s field changes, the record ID MAY appear in multiple buckets historically; readers MUST validate current field value when interpreting results.
932932
- Index accessors MUST be paginated (offset + limit) and MUST cap `limit`.
933933

934+
**Tokenized index (secondary index; when `onChainIndexing=true`)**
935+
For each tokenized indexed field `f`:
936+
- `indexC_f: mapping(bytes32 => uint256[])` mapping of normalized token key to append-only list of candidate record IDs.
937+
- Tokenized indexes MUST apply deterministic extraction and normalization rules defined by the configured tokenizer.
938+
- In v1, the only supported tokenizer is `hashtag`:
939+
- source field type MUST be `string`,
940+
- tokens begin with `#`,
941+
- the token body is limited to ASCII letters, digits, and underscore,
942+
- normalized token content is lowercased ASCII without the leading `#`,
943+
- empty markers (e.g. a bare `#`) MUST be ignored,
944+
- duplicate tokens from the same write MUST be de-duplicated before bucket append.
945+
- If a record’s field changes, the record ID MAY appear in multiple token buckets historically; readers MUST validate current field value when interpreting results.
946+
- Token extraction during create/update MUST be bounded. In v1, a safe default is:
947+
- maximum extracted tokens per indexed field: `8`
948+
- maximum normalized token length: `32` bytes
949+
- writes that exceed those bounds MUST revert.
950+
- If chain config provides stricter tokenized-index guidance (for example `limits.indexing.tokenized.maxTokens` and `limits.indexing.tokenized.maxTokenLength`), the generator SHOULD use those values or stricter values.
951+
- Index accessors MUST be paginated (offset + limit) and MUST cap `limit`.
952+
934953
**Reference reverse index (when `onChainIndexing=true`)**
935954
For reference fields `ref`:
936955
- `refIndexC_ref: mapping(uint256 => uint256[])` mapping of referenced ID to append-only list of record IDs.
@@ -939,6 +958,7 @@ For reference fields `ref`:
939958
**Index accessor semantics (normative)**
940959
- Index accessors MUST return **candidate IDs only**. Callers MUST validate record existence, deletion status, and current field values via `getC`/`existsC` when correctness matters.
941960
- For append-only equality/reference indexes, ordering MUST be insertion order (oldest to newest) within the bucket.
961+
- For append-only tokenized indexes, ordering MUST be insertion order (oldest to newest) within the bucket.
942962
- For swap-and-pop sets (e.g., owner index sets), ordering MUST NOT be relied upon.
943963
- Index accessors MUST be paginated and MUST cap `limit` to avoid RPC timeouts.
944964

@@ -957,6 +977,14 @@ This produces a uniform `bytes32` key space.
957977

958978
Normalization note: Token Host does not implicitly normalize string values on-chain. If an app requires case-insensitive uniqueness or trimmed matching, the schema/UI MUST enforce canonicalization by storing the canonical form as the field value.
959979

980+
For tokenized hashtag indexes:
981+
- the lookup input is the normalized token body without the leading `#`,
982+
- normalization MUST lowercase ASCII `A-Z` to `a-z`,
983+
- only ASCII letters, digits, and underscore are preserved in the token body,
984+
- the key is `keccak256(bytes(normalizedToken))`.
985+
986+
This allows generated UIs, self-hosted runtimes, and external indexers to derive the same lookup key without replaying contract internals.
987+
960988
### 7.9 Event model
961989

962990
Events are the primary integration surface for indexers and external analytics.

packages/cli/src/index.ts

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createRequire } from 'module';
99
import { fileURLToPath, pathToFileURL } from 'url';
1010
import { Command } from 'commander';
1111

12-
import { generateAppSolidity } from '@tokenhost/generator';
12+
import { generateAppSolidity, type GeneratorLimits } from '@tokenhost/generator';
1313
import {
1414
computeSchemaHash,
1515
importLegacyContractsJson,
@@ -1442,6 +1442,76 @@ function titleFromSlug(slug: string): string {
14421442

14431443
type KnownChainName = 'anvil' | 'sepolia' | 'filecoin_calibration' | 'filecoin_mainnet';
14441444

1445+
const DEFAULT_GENERATION_LIMITS: Required<GeneratorLimits> = {
1446+
listMaxLimit: 50,
1447+
listMaxScanSteps: 1000,
1448+
multicallMaxCalls: 20,
1449+
tokenizedIndexMaxTokens: 8,
1450+
tokenizedIndexMaxTokenLength: 32
1451+
};
1452+
1453+
const MAX_GENERATION_LIMITS: Required<GeneratorLimits> = {
1454+
listMaxLimit: 100,
1455+
listMaxScanSteps: 5000,
1456+
multicallMaxCalls: 50,
1457+
tokenizedIndexMaxTokens: 16,
1458+
tokenizedIndexMaxTokenLength: 64
1459+
};
1460+
1461+
function clampGeneratorLimit(value: number | undefined, fallback: number, cap: number): number {
1462+
const numeric = Number(value);
1463+
if (!Number.isFinite(numeric) || numeric < 1) return fallback;
1464+
return Math.min(Math.floor(numeric), cap);
1465+
}
1466+
1467+
function generationLimitsForChain(chainName?: KnownChainName): Required<GeneratorLimits> {
1468+
const chainDefaults: Partial<Record<KnownChainName, Partial<Required<GeneratorLimits>>>> = {
1469+
filecoin_calibration: {
1470+
listMaxLimit: 25,
1471+
listMaxScanSteps: 500,
1472+
multicallMaxCalls: 12,
1473+
tokenizedIndexMaxTokens: 6,
1474+
tokenizedIndexMaxTokenLength: 24
1475+
},
1476+
filecoin_mainnet: {
1477+
listMaxLimit: 25,
1478+
listMaxScanSteps: 500,
1479+
multicallMaxCalls: 12,
1480+
tokenizedIndexMaxTokens: 6,
1481+
tokenizedIndexMaxTokenLength: 24
1482+
}
1483+
};
1484+
1485+
const selected = {
1486+
...DEFAULT_GENERATION_LIMITS,
1487+
...(chainName ? chainDefaults[chainName] ?? {} : {})
1488+
};
1489+
1490+
return {
1491+
listMaxLimit: clampGeneratorLimit(selected.listMaxLimit, DEFAULT_GENERATION_LIMITS.listMaxLimit, MAX_GENERATION_LIMITS.listMaxLimit),
1492+
listMaxScanSteps: clampGeneratorLimit(
1493+
selected.listMaxScanSteps,
1494+
DEFAULT_GENERATION_LIMITS.listMaxScanSteps,
1495+
MAX_GENERATION_LIMITS.listMaxScanSteps
1496+
),
1497+
multicallMaxCalls: clampGeneratorLimit(
1498+
selected.multicallMaxCalls,
1499+
DEFAULT_GENERATION_LIMITS.multicallMaxCalls,
1500+
MAX_GENERATION_LIMITS.multicallMaxCalls
1501+
),
1502+
tokenizedIndexMaxTokens: clampGeneratorLimit(
1503+
selected.tokenizedIndexMaxTokens,
1504+
DEFAULT_GENERATION_LIMITS.tokenizedIndexMaxTokens,
1505+
MAX_GENERATION_LIMITS.tokenizedIndexMaxTokens
1506+
),
1507+
tokenizedIndexMaxTokenLength: clampGeneratorLimit(
1508+
selected.tokenizedIndexMaxTokenLength,
1509+
DEFAULT_GENERATION_LIMITS.tokenizedIndexMaxTokenLength,
1510+
MAX_GENERATION_LIMITS.tokenizedIndexMaxTokenLength
1511+
)
1512+
};
1513+
}
1514+
14451515
function resolveKnownChain(name: string): { chainName: KnownChainName; chain: any } {
14461516
const n = name.toLowerCase().trim();
14471517
if (n === 'anvil') return { chainName: 'anvil', chain: anvil };
@@ -1471,6 +1541,7 @@ function resolveRpcUrl(chainName: KnownChainName, chain: any, override?: string)
14711541
function buildChainConfigArtifact(args: { chainName: KnownChainName; chain: any; rpcUrl: string }): any {
14721542
const now = new Date().toISOString();
14731543
const isLocal = args.chainName === 'anvil';
1544+
const generationLimits = generationLimitsForChain(args.chainName);
14741545

14751546
const chainConfig: any = {
14761547
chainConfigVersion: '1.0.0',
@@ -1501,6 +1572,21 @@ function buildChainConfigArtifact(args: { chainName: KnownChainName; chain: any;
15011572
}
15021573
]
15031574
},
1575+
limits: {
1576+
lists: {
1577+
maxLimit: generationLimits.listMaxLimit,
1578+
maxScanSteps: generationLimits.listMaxScanSteps
1579+
},
1580+
multicall: {
1581+
maxCalls: generationLimits.multicallMaxCalls
1582+
},
1583+
indexing: {
1584+
tokenized: {
1585+
maxTokens: generationLimits.tokenizedIndexMaxTokens,
1586+
maxTokenLength: generationLimits.tokenizedIndexMaxTokenLength
1587+
}
1588+
}
1589+
},
15041590
issuer: {
15051591
name: 'Token Host (local)',
15061592
issuedAt: now
@@ -2360,14 +2446,16 @@ function buildFromSchema(
23602446
txMode?: string;
23612447
relayBaseUrl?: string;
23622448
targetChainId?: number;
2449+
targetChainName?: KnownChainName;
23632450
compileProfile?: CompileProfile;
23642451
}
23652452
): { outDir: string; uiBundleDir: string | null; uiSiteDir: string | null } {
23662453
const resolvedOutDir = path.resolve(outDir);
23672454
ensureDir(resolvedOutDir);
2455+
const generationLimits = generationLimitsForChain(opts.targetChainName);
23682456

23692457
// 1) Generate Solidity source
2370-
const appSol = generateAppSolidity(schema);
2458+
const appSol = generateAppSolidity(schema, { limits: generationLimits });
23712459
ensureDir(path.join(resolvedOutDir, path.dirname(appSol.path)));
23722460
fs.writeFileSync(path.join(resolvedOutDir, appSol.path), appSol.contents);
23732461

@@ -2409,7 +2497,9 @@ function buildFromSchema(
24092497
fs.rmSync(uiBundleDir, { recursive: true, force: true });
24102498
ensureDir(uiBundleDir);
24112499

2412-
const uiWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokenhost-ui-build-'));
2500+
const uiTempRoot = path.join(resolvedOutDir, '.tokenhost-build-tmp');
2501+
ensureDir(uiTempRoot);
2502+
const uiWorkDir = fs.mkdtempSync(path.join(uiTempRoot, 'ui-build-'));
24132503
try {
24142504
const templateDir = resolveNextExportUiTemplateDir();
24152505
copyDir(templateDir, uiWorkDir);
@@ -2443,6 +2533,7 @@ function buildFromSchema(
24432533
copyDir(exportedDir, uiBundleDir);
24442534
} finally {
24452535
fs.rmSync(uiWorkDir, { recursive: true, force: true });
2536+
fs.rmSync(uiTempRoot, { recursive: true, force: true });
24462537
}
24472538

24482539
uiBundleDigest = computeDirectoryDigest(uiBundleDir);
@@ -3257,9 +3348,10 @@ program
32573348
.argument('<schema>', 'Path to THS schema JSON file')
32583349
.option('--out <dir>', 'Output directory', 'artifacts')
32593350
.option('--no-ui', 'Do not generate UI output')
3351+
.option('--chain <name>', 'Target chain for generation limits (anvil|sepolia|filecoin_calibration|filecoin_mainnet)')
32603352
.option('--compiler-profile <profile>', 'Compiler profile (auto|default|large-app)', 'auto')
32613353
.option('--with-tests', 'Emit generated app test scaffold', false)
3262-
.action((schemaPath: string, opts: { out: string; ui: boolean; compilerProfile?: string; withTests: boolean }) => {
3354+
.action((schemaPath: string, opts: { out: string; ui: boolean; chain?: string; compilerProfile?: string; withTests: boolean }) => {
32633355
const input = readJsonFile(schemaPath);
32643356
const structural = validateThsStructural(input);
32653357
if (!structural.ok) {
@@ -3278,7 +3370,8 @@ program
32783370
}
32793371

32803372
const outDir = opts.out;
3281-
const appSol = generateAppSolidity(schema);
3373+
const generationLimits = generationLimitsForChain(opts.chain ? resolveKnownChain(opts.chain).chainName : undefined);
3374+
const appSol = generateAppSolidity(schema, { limits: generationLimits });
32823375
const contractsDir = path.join(outDir, path.dirname(appSol.path));
32833376
ensureDir(contractsDir);
32843377
fs.writeFileSync(path.join(outDir, appSol.path), appSol.contents);
@@ -3348,18 +3441,22 @@ program
33483441
.argument('<schema>', 'Path to THS schema JSON file')
33493442
.option('--out <dir>', 'Output directory', 'artifacts')
33503443
.option('--no-ui', 'Do not generate/build UI bundle')
3444+
.option('--chain <name>', 'Target chain for generation limits (anvil|sepolia|filecoin_calibration|filecoin_mainnet)')
33513445
.option('--compiler-profile <profile>', 'Compiler profile (auto|default|large-app)', 'auto')
33523446
.option('--tx-mode <mode>', 'Transaction mode (auto|userPays|sponsored)', 'auto')
33533447
.option('--relay-base-url <url>', 'Relay base URL for sponsored mode', '/__tokenhost/relay')
3354-
.action((schemaPath: string, opts: { out: string; ui: boolean; compilerProfile?: string; txMode?: string; relayBaseUrl?: string }) => {
3448+
.action((schemaPath: string, opts: { out: string; ui: boolean; chain?: string; compilerProfile?: string; txMode?: string; relayBaseUrl?: string }) => {
33553449
try {
33563450
const schema = loadThsSchemaOrThrow(schemaPath);
3451+
const targetChainName = opts.chain ? resolveKnownChain(opts.chain).chainName : undefined;
33573452
buildFromSchema(schema, opts.out, {
33583453
ui: opts.ui,
33593454
schemaPathForHints: schemaPath,
33603455
compileProfile: normalizeCompileProfile(opts.compilerProfile),
33613456
txMode: opts.txMode,
3362-
relayBaseUrl: opts.relayBaseUrl
3457+
relayBaseUrl: opts.relayBaseUrl,
3458+
targetChainName,
3459+
targetChainId: targetChainName ? resolveKnownChain(targetChainName).chain.id : undefined
33633460
});
33643461
} catch (e: any) {
33653462
console.error(String(e?.message ?? e));
@@ -3548,7 +3645,8 @@ program
35483645
compileProfile,
35493646
txMode: opts.txMode,
35503647
relayBaseUrl: opts.relayBaseUrl,
3551-
targetChainId: chain.id
3648+
targetChainId: chain.id,
3649+
targetChainName: chainName
35523650
});
35533651
console.log('Build complete.');
35543652

packages/generator/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { generateAppSolidity } from './solidity/generateAppSolidity.js';
2-
2+
export type { GenerateAppSolidityOptions, GeneratorLimits } from './solidity/generateAppSolidity.js';

packages/generator/src/solidity/generateAppSolidity.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import type { Access, Collection, FieldType, QueryIndex, Relation, ThsField, ThsSchema, UniqueIndex } from '@tokenhost/schema';
22
import { computeSchemaHash } from '@tokenhost/schema';
33

4+
export type GeneratorLimits = {
5+
listMaxLimit?: number;
6+
listMaxScanSteps?: number;
7+
multicallMaxCalls?: number;
8+
tokenizedIndexMaxTokens?: number;
9+
tokenizedIndexMaxTokenLength?: number;
10+
};
11+
12+
export type GenerateAppSolidityOptions = {
13+
limits?: GeneratorLimits;
14+
};
15+
16+
const DEFAULT_GENERATOR_LIMITS = Object.freeze({
17+
listMaxLimit: 50,
18+
listMaxScanSteps: 1000,
19+
multicallMaxCalls: 20,
20+
tokenizedIndexMaxTokens: 8,
21+
tokenizedIndexMaxTokenLength: 32
22+
});
23+
424
type SolidityType = 'string' | 'uint256' | 'int256' | 'bool' | 'address' | 'bytes32';
525

626
function solidityStorageType(t: FieldType): SolidityType {
@@ -114,14 +134,18 @@ function recordHashFnName(collectionName: string): string {
114134
return `_hashRecord${collectionName}`;
115135
}
116136

117-
export function generateAppSolidity(schema: ThsSchema): { path: string; contents: string } {
137+
export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolidityOptions = {}): { path: string; contents: string } {
118138
const w = new W();
119139

120140
const schemaHash = computeSchemaHash(schema);
121141
const schemaHashBytes32 = bytes32FromSha256(schemaHash);
122142

123143
const onChainIndexing = schema.app.features?.onChainIndexing ?? true;
124144
const anyPaidCreates = schema.collections.some(hasPaidCreates);
145+
const limits = {
146+
...DEFAULT_GENERATOR_LIMITS,
147+
...(options.limits ?? {})
148+
};
125149

126150
w.line('// SPDX-License-Identifier: UNLICENSED');
127151
w.line('pragma solidity ^0.8.24;');
@@ -147,11 +171,11 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
147171
w.line();
148172

149173
w.line(`bool public constant ON_CHAIN_INDEXING = ${onChainIndexing ? 'true' : 'false'};`);
150-
w.line('uint256 public constant MAX_LIST_LIMIT = 50;');
151-
w.line('uint256 public constant MAX_SCAN_STEPS = 1000;');
152-
w.line('uint256 public constant MAX_MULTICALL_CALLS = 20;');
153-
w.line('uint256 public constant MAX_TOKENIZED_INDEX_TOKENS = 8;');
154-
w.line('uint256 public constant MAX_TOKENIZED_INDEX_TOKEN_LENGTH = 32;');
174+
w.line(`uint256 public constant MAX_LIST_LIMIT = ${limits.listMaxLimit};`);
175+
w.line(`uint256 public constant MAX_SCAN_STEPS = ${limits.listMaxScanSteps};`);
176+
w.line(`uint256 public constant MAX_MULTICALL_CALLS = ${limits.multicallMaxCalls};`);
177+
w.line(`uint256 public constant MAX_TOKENIZED_INDEX_TOKENS = ${limits.tokenizedIndexMaxTokens};`);
178+
w.line(`uint256 public constant MAX_TOKENIZED_INDEX_TOKEN_LENGTH = ${limits.tokenizedIndexMaxTokenLength};`);
155179
w.line();
156180

157181
// Errors

schemas/tokenhost-chain-config.schema.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,26 @@
315315
}
316316
}
317317
},
318+
"indexing": {
319+
"type": "object",
320+
"additionalProperties": false,
321+
"properties": {
322+
"tokenized": {
323+
"type": "object",
324+
"additionalProperties": false,
325+
"properties": {
326+
"maxTokens": {
327+
"type": "integer",
328+
"minimum": 1
329+
},
330+
"maxTokenLength": {
331+
"type": "integer",
332+
"minimum": 1
333+
}
334+
}
335+
}
336+
}
337+
},
318338
"realtime": {
319339
"type": "object",
320340
"additionalProperties": false,

0 commit comments

Comments
 (0)