Skip to content

Commit 81bee70

Browse files
committed
added the OR flow
1 parent 74ee9ac commit 81bee70

4 files changed

Lines changed: 267 additions & 50 deletions

File tree

src/core/encoding/abis.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
// Reusable ABI shape for a Constraint struct — used when ABI-encoding OR sub-constraints
2+
export const CONSTRAINT_TUPLE_ABI = {
3+
type: 'tuple[]',
4+
components: [
5+
{ name: 'constraintType', type: 'uint8' },
6+
{ name: 'referenceData', type: 'bytes' },
7+
],
8+
} as const;
9+
110
export const COMPOSABILITY_MODULE_ABI_V1_1_0 = [
211
{
312
type: 'constructor',

src/core/encoding/encoding.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
greaterThanOrEqualToSigned,
77
lessThanOrEqualTo,
88
lessThanOrEqualToSigned,
9+
orConstraint,
910
toConstraintFields,
1011
validateAndProcessConstraints,
1112
} from './encoding';
@@ -404,3 +405,159 @@ describe('validateAndProcessConstraints — general behaviour', () => {
404405
).toThrow('Invalid constraint type');
405406
});
406407
});
408+
409+
// ---------------------------------------------------------------------------
410+
// orConstraint helper
411+
// ---------------------------------------------------------------------------
412+
413+
describe('orConstraint helper', () => {
414+
it('returns a ConstraintField with type OR', () => {
415+
const field = orConstraint([equalTo(0n)]);
416+
expect(field.type).toBe(ConstraintType.OR);
417+
});
418+
419+
it('stores the sub-constraints array as the value', () => {
420+
const subs = [greaterThanOrEqualTo(10n), equalTo(0n)];
421+
const field = orConstraint(subs);
422+
expect(field.value).toBe(subs);
423+
});
424+
});
425+
426+
// ---------------------------------------------------------------------------
427+
// toConstraintFields — OR key
428+
// ---------------------------------------------------------------------------
429+
430+
describe('toConstraintFields — OR', () => {
431+
it('maps { or: [...] } to an OR ConstraintField', () => {
432+
const [field] = toConstraintFields([{ or: [{ eq: 0n }, { gte: 100n }] }]);
433+
expect(field.type).toBe(ConstraintType.OR);
434+
});
435+
436+
it('sub-constraints inside OR are converted to ConstraintFields stored in value', () => {
437+
const [field] = toConstraintFields([{ or: [{ eq: 0n }, { gte: 100n }] }]);
438+
const subs = field.value as ReturnType<typeof equalTo>[];
439+
expect(subs).toHaveLength(2);
440+
expect(subs[0].type).toBe(ConstraintType.EQ);
441+
expect(subs[1].type).toBe(ConstraintType.GTE);
442+
});
443+
444+
it('OR sub-constraints can include signed variants (gteSigned, lteSigned)', () => {
445+
const [field] = toConstraintFields([{ or: [{ gteSigned: -1n }, { lteSigned: 0n }] }]);
446+
const subs = field.value as ReturnType<typeof greaterThanOrEqualToSigned>[];
447+
expect(subs[0].type).toBe(ConstraintType.GTE_SIGNED);
448+
expect(subs[1].type).toBe(ConstraintType.LTE_SIGNED);
449+
});
450+
451+
it('OR can appear alongside other constraints in the same array — order is preserved', () => {
452+
const fields = toConstraintFields([
453+
{ gte: 10n },
454+
{ or: [{ eq: 0n }, { gte: 100n }] },
455+
{ lte: 500n },
456+
]);
457+
expect(fields[0].type).toBe(ConstraintType.GTE);
458+
expect(fields[1].type).toBe(ConstraintType.OR);
459+
expect(fields[2].type).toBe(ConstraintType.LTE);
460+
});
461+
});
462+
463+
// ---------------------------------------------------------------------------
464+
// validateAndProcessConstraints — OR
465+
// ---------------------------------------------------------------------------
466+
467+
describe('validateAndProcessConstraints — OR', () => {
468+
it('produces a constraint with type OR', () => {
469+
const [c] = validateAndProcessConstraints([orConstraint([equalTo(0n)])]);
470+
expect(c.constraintType).toBe(ConstraintType.OR);
471+
});
472+
473+
it('referenceData is a non-empty hex string (ABI-encoded Constraint[] for the sub-constraints)', () => {
474+
const [c] = validateAndProcessConstraints([
475+
orConstraint([equalTo(0n), greaterThanOrEqualTo(100n)]),
476+
]);
477+
expect(c.referenceData).toMatch(/^0x[0-9a-f]+$/i);
478+
expect(c.referenceData.length).toBeGreaterThan(2);
479+
});
480+
481+
it('OR with unsigned sub-constraints — EQ and GTE — encodes without error', () => {
482+
expect(() =>
483+
validateAndProcessConstraints([orConstraint([equalTo(0n), greaterThanOrEqualTo(100n)])]),
484+
).not.toThrow();
485+
});
486+
487+
it('OR with unsigned sub-constraints — EQ and LTE — encodes without error', () => {
488+
expect(() =>
489+
validateAndProcessConstraints([orConstraint([equalTo(999n), lessThanOrEqualTo(50n)])]),
490+
).not.toThrow();
491+
});
492+
493+
it('OR with signed sub-constraint GTE_SIGNED — accepts negative reference value', () => {
494+
expect(() =>
495+
validateAndProcessConstraints([orConstraint([greaterThanOrEqualToSigned(-1n), equalTo(0n)])]),
496+
).not.toThrow();
497+
const [c] = validateAndProcessConstraints([
498+
orConstraint([greaterThanOrEqualToSigned(-1n), equalTo(0n)]),
499+
]);
500+
expect(c.constraintType).toBe(ConstraintType.OR);
501+
});
502+
503+
it('OR with signed sub-constraint LTE_SIGNED — accepts negative reference value', () => {
504+
expect(() =>
505+
validateAndProcessConstraints([orConstraint([lessThanOrEqualToSigned(-100n)])]),
506+
).not.toThrow();
507+
});
508+
509+
it('OR alongside other constraints — all constraints are processed in order', () => {
510+
const result = validateAndProcessConstraints([
511+
greaterThanOrEqualTo(10n),
512+
orConstraint([equalTo(0n), greaterThanOrEqualTo(500n)]),
513+
lessThanOrEqualTo(1000n),
514+
]);
515+
expect(result).toHaveLength(3);
516+
expect(result[0].constraintType).toBe(ConstraintType.GTE);
517+
expect(result[1].constraintType).toBe(ConstraintType.OR);
518+
expect(result[2].constraintType).toBe(ConstraintType.LTE);
519+
});
520+
521+
it('referenceData changes when sub-constraints change — different inputs produce different encodings', () => {
522+
const [or1] = validateAndProcessConstraints([orConstraint([equalTo(0n)])]);
523+
const [or2] = validateAndProcessConstraints([orConstraint([equalTo(999n)])]);
524+
expect(or1.referenceData).not.toBe(or2.referenceData);
525+
});
526+
527+
it('rejects nested OR — OR inside OR is not supported by the contract', () => {
528+
expect(() =>
529+
validateAndProcessConstraints([
530+
orConstraint([{ type: ConstraintType.OR, value: [equalTo(0n)] }]),
531+
]),
532+
).toThrow('Nested OR constraints are not supported');
533+
});
534+
535+
it('rejects an OR with an empty sub-constraints array', () => {
536+
expect(() => validateAndProcessConstraints([orConstraint([])])).toThrow(
537+
'OR constraint must have at least one sub-constraint',
538+
);
539+
});
540+
541+
it('rejects an OR sub-constraint with an invalid type', () => {
542+
expect(() =>
543+
validateAndProcessConstraints([
544+
// biome-ignore lint/suspicious/noExplicitAny: intentional invalid type for test
545+
orConstraint([{ type: 99 as any, value: 0n }]),
546+
]),
547+
).toThrow('Invalid constraint type');
548+
});
549+
550+
it('rejects a GTE_SIGNED sub-constraint inside OR when value is not a bigint', () => {
551+
expect(() =>
552+
validateAndProcessConstraints([
553+
orConstraint([{ type: ConstraintType.GTE_SIGNED, value: true }]),
554+
]),
555+
).toThrow('signed constraints require bigint');
556+
});
557+
558+
it('rejects an unsigned sub-constraint inside OR when value is a negative bigint', () => {
559+
expect(() =>
560+
validateAndProcessConstraints([orConstraint([greaterThanOrEqualTo(-1n)])]),
561+
).toThrow('Invalid constraint value');
562+
});
563+
});

src/core/encoding/encoding.ts

Lines changed: 88 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
import { NAMESPACE_STORAGE_CONTRACT_ADDRESS } from '../storage/constants';
1717
import { getBaseStorageSlot } from '../storage/slot';
1818
import type { AnyData } from '../types';
19-
import { COMPOSABILITY_MODULE_ABI_V1_1_0 } from './abis';
19+
import { COMPOSABILITY_MODULE_ABI_V1_1_0, CONSTRAINT_TUPLE_ABI } from './abis';
2020
import {
2121
encodeAddress,
2222
encodeRuntimeFunctionData,
2323
getFunctionContextFromAbi,
2424
} from './runtimeAbiEncoding';
2525
import {
2626
type Capture,
27+
type ChildConstraint,
2728
type ComposableCall,
2829
type Constraint,
2930
type ConstraintField,
@@ -89,6 +90,11 @@ export const lessThanOrEqualToSigned = (value: AnyData): ConstraintField => {
8990
return { type: ConstraintType.LTE_SIGNED, value };
9091
};
9192

93+
// value is ConstraintField[] — the sub-constraints that will be ABI-encoded as OR's referenceData
94+
export const orConstraint = (subConstraints: ConstraintField[]): ConstraintField => {
95+
return { type: ConstraintType.OR, value: subConstraints };
96+
};
97+
9298
export const runtimeParamViaCustomStaticCall = ({
9399
targetContractAddress,
94100
functionAbi,
@@ -218,60 +224,91 @@ const SIGNED_CONSTRAINT_TYPES = new Set<ConstraintType>([
218224
ConstraintType.LTE_SIGNED,
219225
]);
220226

221-
const ALLOWED_CONSTRAINT_TYPES = new Set<ConstraintType>([
227+
// Child types: all constraint types that can appear standalone or inside an OR.
228+
// OR itself is not here — it is handled separately and cannot be nested.
229+
const CHILD_CONSTRAINT_TYPES = new Set<ConstraintType>([
222230
ConstraintType.EQ,
223231
ConstraintType.GTE,
224232
ConstraintType.LTE,
225233
ConstraintType.GTE_SIGNED,
226234
ConstraintType.LTE_SIGNED,
227235
]);
228236

237+
/**
238+
* Validates and encodes a single child constraint (non-OR).
239+
* Throws if the type is OR — nested OR is not supported by the contract.
240+
*/
241+
const validateAndProcessChildConstraint = (constraint: ConstraintField): Constraint => {
242+
if (constraint.type === ConstraintType.OR) {
243+
throw new Error('Nested OR constraints are not supported');
244+
}
245+
246+
if (!CHILD_CONSTRAINT_TYPES.has(constraint.type)) {
247+
throw new Error('Invalid constraint type');
248+
}
249+
250+
const isSigned = SIGNED_CONSTRAINT_TYPES.has(constraint.type);
251+
252+
if (isSigned) {
253+
// Signed constraints only accept bigint (including negative)
254+
if (typeof constraint.value !== 'bigint') {
255+
throw new Error('Invalid constraint value: signed constraints require bigint');
256+
}
257+
258+
// Encode as int256 to preserve two's-complement representation
259+
const valueHex = encodeAbiParameters([{ type: 'int256' }], [constraint.value]);
260+
const encodedConstraintValue = encodeAbiParameters([{ type: 'bytes32' }], [valueHex as Hex]);
261+
return prepareConstraint(constraint.type, encodedConstraintValue);
262+
}
263+
264+
// Unsigned / address / bool / hex path
265+
if (
266+
typeof constraint.value !== 'bigint' &&
267+
typeof constraint.value !== 'boolean' &&
268+
!isHex(constraint.value) &&
269+
!isAddress(constraint.value)
270+
) {
271+
throw new Error('Invalid constraint value');
272+
}
273+
274+
if (typeof constraint.value === 'bigint' && constraint.value < BigInt(0)) {
275+
throw new Error('Invalid constraint value');
276+
}
277+
278+
const valueHex = toBytes32(constraint.value);
279+
const encodedConstraintValue = encodeAbiParameters([{ type: 'bytes32' }], [valueHex as Hex]);
280+
return prepareConstraint(constraint.type, encodedConstraintValue);
281+
};
282+
229283
export const validateAndProcessConstraints = (constraints: ConstraintField[]): Constraint[] => {
230284
const constraintsToAdd: Constraint[] = [];
231285

232-
if (constraints.length > 0) {
233-
for (const constraint of constraints) {
234-
if (!ALLOWED_CONSTRAINT_TYPES.has(constraint.type)) {
235-
throw new Error('Invalid constraint type');
286+
for (const constraint of constraints) {
287+
if (constraint.type === ConstraintType.OR) {
288+
// value must be ConstraintField[] — the sub-constraints to OR together
289+
if (!Array.isArray(constraint.value) || constraint.value.length === 0) {
290+
throw new Error('OR constraint must have at least one sub-constraint');
236291
}
237292

238-
const isSigned = SIGNED_CONSTRAINT_TYPES.has(constraint.type);
293+
// Process each sub-constraint through the child path — nested OR is rejected inside
294+
const processedSubs: Constraint[] = (constraint.value as ConstraintField[]).map(
295+
validateAndProcessChildConstraint,
296+
);
239297

240-
if (isSigned) {
241-
// Signed constraints only accept bigint (including negative)
242-
if (typeof constraint.value !== 'bigint') {
243-
throw new Error('Invalid constraint value: signed constraints require bigint');
244-
}
298+
// Encode the Constraint[] array as abi.encode(Constraint[]) — what the contract decodes
299+
const encodedSubs = encodeAbiParameters(
300+
[CONSTRAINT_TUPLE_ABI],
301+
[
302+
processedSubs.map((c) => ({
303+
constraintType: c.constraintType,
304+
referenceData: c.referenceData as Hex,
305+
})),
306+
],
307+
);
245308

246-
// Encode as int256 to preserve two's-complement representation
247-
const valueHex = encodeAbiParameters([{ type: 'int256' }], [constraint.value]);
248-
const encodedConstraintValue = encodeAbiParameters(
249-
[{ type: 'bytes32' }],
250-
[valueHex as Hex],
251-
);
252-
constraintsToAdd.push(prepareConstraint(constraint.type, encodedConstraintValue));
253-
} else {
254-
// Handle value validation appropriate to runtime function
255-
if (
256-
typeof constraint.value !== 'bigint' &&
257-
typeof constraint.value !== 'boolean' &&
258-
!isHex(constraint.value) &&
259-
!isAddress(constraint.value)
260-
) {
261-
throw new Error('Invalid constraint value');
262-
}
263-
264-
if (typeof constraint.value === 'bigint' && constraint.value < BigInt(0)) {
265-
throw new Error('Invalid constraint value');
266-
}
267-
268-
const valueHex = toBytes32(constraint.value);
269-
const encodedConstraintValue = encodeAbiParameters(
270-
[{ type: 'bytes32' }],
271-
[valueHex as Hex],
272-
);
273-
constraintsToAdd.push(prepareConstraint(constraint.type, encodedConstraintValue));
274-
}
309+
constraintsToAdd.push(prepareConstraint(ConstraintType.OR, encodedSubs));
310+
} else {
311+
constraintsToAdd.push(validateAndProcessChildConstraint(constraint));
275312
}
276313
}
277314

@@ -280,15 +317,19 @@ export const validateAndProcessConstraints = (constraints: ConstraintField[]): C
280317

281318
/**
282319
* Maps the user-facing RuntimeConstraint format to the internal ConstraintField format.
283-
* Handles gte, lte, and eq constraint types.
284320
*/
321+
const toChildConstraintField = (c: ChildConstraint): ConstraintField => {
322+
if ('gte' in c) return greaterThanOrEqualTo(c.gte);
323+
if ('lte' in c) return lessThanOrEqualTo(c.lte);
324+
if ('gteSigned' in c) return greaterThanOrEqualToSigned(c.gteSigned);
325+
if ('lteSigned' in c) return lessThanOrEqualToSigned(c.lteSigned);
326+
return equalTo(c.eq);
327+
};
328+
285329
export const toConstraintFields = (constraints: RuntimeConstraint[]): ConstraintField[] =>
286330
constraints.map((c) => {
287-
if ('gte' in c) return greaterThanOrEqualTo(c.gte);
288-
if ('lte' in c) return lessThanOrEqualTo(c.lte);
289-
if ('gteSigned' in c) return greaterThanOrEqualToSigned(c.gteSigned);
290-
if ('lteSigned' in c) return lessThanOrEqualToSigned(c.lteSigned);
291-
return equalTo(c.eq);
331+
if ('or' in c) return orConstraint(c.or.map(toChildConstraintField));
332+
return toChildConstraintField(c);
292333
});
293334

294335
export const prepareTargetAndValueInputParams = (

0 commit comments

Comments
 (0)