Skip to content
Open
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
123 changes: 123 additions & 0 deletions src/sdk/account/decorators/instructions/buildComposable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
getMEEVersion,
greaterThanOrEqualTo,
greaterThanOrEqualToSigned,
inRange,
lessThanOrEqualTo,
lessThanOrEqualToSigned,
orConstraint,
Expand Down Expand Up @@ -2125,6 +2126,128 @@ describe.runIf(runLifecycleTests)("mee.buildComposable", () => {
)
})

it("should execute composable sweep with inRange constraint", async () => {
const amountToSupply = parseUnits("0.1", 6)
const amountToTransfer = parseUnits("0.05", 6)
// Range: 1 wei .. 100 USDC — the transferred balance will always fall inside
const lowerBound = 1n
const upperBound = parseUnits("100", 6)

for (const {
name,
mcNexus: testMcNexus,
meeClient: testMeeClient
} of accountConfigs) {
const staticTransferInstruction = await testMcNexus.buildComposable({
type: "transfer",
data: {
recipient: runtimeTransferAddress as Address,
tokenAddress,
amount: amountToTransfer,
chainId: chain.id
}
})

const sweepInstruction = await testMcNexus.buildComposable({
type: "default",
data: {
to: runtimeTransferAddress,
abi: COMPOSABILITY_RUNTIME_TRANSFER_ABI as Abi,
functionName: "transferFunds",
args: [
tokenAddress,
eoaAccount.address,
runtimeERC20BalanceOf({
targetAddress: runtimeTransferAddress,
tokenAddress,
constraints: [inRange(lowerBound, upperBound)]
})
],
chainId: chain.id
}
})

const { hash } = await testMeeClient.executeFusionQuote({
fusionQuote: await testMeeClient.getFusionQuote({
trigger: { chainId: chain.id, tokenAddress, amount: amountToSupply },
instructions: [...staticTransferInstruction, ...sweepInstruction],
feeToken: { chainId: chain.id, address: tokenAddress }
})
})

const { transactionStatus, explorerLinks } =
await testMeeClient.waitForSupertransactionReceipt({
hash,
confirmations: TEST_BLOCK_CONFIRMATIONS
})
expect(transactionStatus).to.be.eq("MINED_SUCCESS")
console.log(`[${name}] inRange constraint sweep:`, {
explorerLinks,
hash
})
}
})

it("should execute composable sweep with OR containing inRange sub-constraint on composability v1.1.1", async () => {
const amountToSupply = parseUnits("0.1", 6)
const amountToTransfer = parseUnits("0.05", 6)

const staticTransferInstruction =
await mcNexus_compos_v1_1_1.buildComposable({
type: "transfer",
data: {
recipient: runtimeTransferAddress as Address,
tokenAddress,
amount: amountToTransfer,
chainId: chain.id
}
})

// OR(inRange(1, 0.05 USDC), GTE(0.1 USDC)) — first branch matches the transferred amount
const sweepInstruction = await mcNexus_compos_v1_1_1.buildComposable({
type: "default",
data: {
to: runtimeTransferAddress,
abi: COMPOSABILITY_RUNTIME_TRANSFER_ABI as Abi,
functionName: "transferFunds",
args: [
tokenAddress,
eoaAccount.address,
runtimeERC20BalanceOf({
targetAddress: runtimeTransferAddress,
tokenAddress,
constraints: [
orConstraint([
inRange(1n, parseUnits("0.05", 6)),
greaterThanOrEqualTo(parseUnits("0.1", 6))
])
]
})
],
chainId: chain.id
}
})

const { hash } = await meeClient_compos_v1_1_1.executeFusionQuote({
fusionQuote: await meeClient_compos_v1_1_1.getFusionQuote({
trigger: { chainId: chain.id, tokenAddress, amount: amountToSupply },
instructions: [...staticTransferInstruction, ...sweepInstruction],
feeToken: { chainId: chain.id, address: tokenAddress }
})
})

const { transactionStatus, explorerLinks } =
await meeClient_compos_v1_1_1.waitForSupertransactionReceipt({
hash,
confirmations: TEST_BLOCK_CONFIRMATIONS
})
expect(transactionStatus).to.be.eq("MINED_SUCCESS")
console.log("[v1.1.1] OR(inRange, GTE) constraint sweep:", {
explorerLinks,
hash
})
})

// test the new 'runtimeParamViaCustomStaticCall' helper function and the injectable target at the same time
it("should execute composable transaction using runtimeParamViaCustomStaticCall (for composability v1.1.0+ only)", async () => {
const amount = 101n // 101 wei
Expand Down
54 changes: 54 additions & 0 deletions src/sdk/modules/utils/composabilityCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export type ChildConstraint =
| { gte: ConstraintValue }
| { lte: ConstraintValue }
| { eq: ConstraintValue }
| { in: { lower: ConstraintValue; upper: ConstraintValue } }
| { gteSigned: bigint }
| { lteSigned: bigint }

Expand Down Expand Up @@ -233,6 +234,13 @@ export const lessThanOrEqualToSigned = (value: AnyData): ConstraintField => {
return { type: ConstraintType.LTE_SIGNED, value }
}

export const inRange = (
lower: ConstraintValue,
upper: ConstraintValue
): ConstraintField => {
return { type: ConstraintType.IN, value: { lower, upper } }
}

export const orConstraint = (
subConstraints: ConstraintField[]
): ConstraintField => {
Expand All @@ -243,6 +251,7 @@ const CHILD_CONSTRAINT_TYPES = new Set<ConstraintType>([
ConstraintType.EQ,
ConstraintType.GTE,
ConstraintType.LTE,
ConstraintType.IN,
ConstraintType.GTE_SIGNED,
ConstraintType.LTE_SIGNED
])
Expand Down Expand Up @@ -280,6 +289,51 @@ const validateAndProcessChildConstraint = (
return { constraintType: constraint.type, referenceData }
}

// IN path — range check: lower <= value <= upper (unsigned bytes32 comparison)
if (constraint.type === ConstraintType.IN) {
const { lower, upper } = constraint.value as {
lower: ConstraintValue
upper: ConstraintValue
}

for (const [label, v] of [
["lower", lower],
["upper", upper]
] as const) {
if (
typeof v !== "bigint" &&
typeof v !== "boolean" &&
!isHex(v) &&
!isAddress(v)
) {
throw new Error(
`Invalid IN constraint: ${label} must be bigint, boolean, hex, or address`
)
}
if (typeof v === "bigint" && v < 0n) {
throw new Error(
`Invalid IN constraint: ${label} must be non-negative (use GTE_SIGNED/LTE_SIGNED for signed ranges)`
)
}
}

if (
typeof lower === "bigint" &&
typeof upper === "bigint" &&
lower > upper
) {
throw new Error("Invalid IN constraint: lower must be <= upper")
}

const lowerHex = toBytes32(lower)
const upperHex = toBytes32(upper)
const referenceData = encodeAbiParameters(
[{ type: "bytes32" }, { type: "bytes32" }],
[lowerHex as Hex, upperHex as Hex]
)
return prepareConstraint(ConstraintType.IN, referenceData)
}

// Unsigned path (EQ, GTE, LTE) — existing logic unchanged
if (
typeof constraint.value !== "bigint" &&
Expand Down
78 changes: 77 additions & 1 deletion src/sdk/modules/utils/conditions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
CONSTRAINT_TUPLE_ABI,
ConstraintType,
InputParamFetcherType,
equalTo,
greaterThanOrEqualTo,
greaterThanOrEqualToSigned,
inRange,
lessThanOrEqualTo,
lessThanOrEqualToSigned,
orConstraint,
Expand Down Expand Up @@ -182,6 +182,82 @@ describe("Conditional Execution - Unit Tests", () => {
})
})

describe("validateAndProcessConstraints — IN range constraint", () => {
test("inRange encodes lower and upper bounds as two bytes32 values", () => {
const result = validateAndProcessConstraints([inRange(10n, 100n)])
expect(result).toHaveLength(1)
expect(result[0].constraintType).toBe(ConstraintType.IN)
// referenceData must encode two bytes32 values (64 bytes = 128 hex chars + 0x prefix)
expect(result[0].referenceData.length).toBe(2 + 128)
})

test("inRange with equal lower and upper (exact value)", () => {
const result = validateAndProcessConstraints([inRange(42n, 42n)])
expect(result).toHaveLength(1)
expect(result[0].constraintType).toBe(ConstraintType.IN)
})

test("inRange with zero lower bound", () => {
const result = validateAndProcessConstraints([inRange(0n, 1000n)])
expect(result).toHaveLength(1)
expect(result[0].constraintType).toBe(ConstraintType.IN)
})

test("inRange referenceData decodes to correct bounds", () => {
const lower = 50n
const upper = 200n
const result = validateAndProcessConstraints([inRange(lower, upper)])
const [decodedLower, decodedUpper] = decodeAbiParameters(
[{ type: "bytes32" }, { type: "bytes32" }],
result[0].referenceData as `0x${string}`
)
expect(BigInt(decodedLower)).toBe(lower)
expect(BigInt(decodedUpper)).toBe(upper)
})

test("inRange throws for negative lower bound", () => {
expect(() => validateAndProcessConstraints([inRange(-1n, 100n)])).toThrow(
"lower must be non-negative"
)
})

test("inRange throws for negative upper bound", () => {
expect(() => validateAndProcessConstraints([inRange(0n, -1n)])).toThrow(
"upper must be non-negative"
)
})

test("inRange throws when lower > upper", () => {
expect(() => validateAndProcessConstraints([inRange(100n, 50n)])).toThrow(
"lower must be <= upper"
)
})

test("inRange throws for invalid lower bound type", () => {
expect(() =>
validateAndProcessConstraints([
inRange("not-hex" as unknown as bigint, 100n)
])
).toThrow("lower must be bigint, boolean, hex, or address")
})

test("inRange can be used inside OR constraint", () => {
const result = validateAndProcessConstraints([
orConstraint([inRange(1n, 50n), greaterThanOrEqualTo(100n)])
])
expect(result).toHaveLength(1)
expect(result[0].constraintType).toBe(ConstraintType.OR)

const [subs] = decodeAbiParameters(
[CONSTRAINT_TUPLE_ABI],
result[0].referenceData as `0x${string}`
)
expect(subs).toHaveLength(2)
expect(subs[0].constraintType).toBe(ConstraintType.IN)
expect(subs[1].constraintType).toBe(ConstraintType.GTE)
})
})

describe("validateAndProcessConstraints — signed integer and OR constraints", () => {
test("greaterThanOrEqualToSigned encodes a positive bigint correctly", () => {
const result = validateAndProcessConstraints([
Expand Down
Loading