Skip to content

feat: dual-path architecture with order manifest support#1

Open
mfw78 wants to merge 25 commits intomainfrom
feat/dual-path-architecture
Open

feat: dual-path architecture with order manifest support#1
mfw78 wants to merge 25 commits intomainfrom
feat/dual-path-architecture

Conversation

@mfw78
Copy link
Copy Markdown

@mfw78 mfw78 commented Feb 13, 2026

Summary

  • Introduces dual-path architecture separating gas-sensitive settlement from rich polling
  • Adds IOrderManifest interface for order enumeration across all order types
  • Emits ConditionalOrderRemoved event on order deauthorization
  • Updates documentation for ERC-1271 wallet support and upstream breaking changes

Breaking Changes

See docs/architecture.md for full migration guide from upstream.

Test Plan

  • All 114 existing tests pass
  • 27 new manifest tests covering all order types
  • Build succeeds with Solidity 0.8.30

mfw78 added 25 commits February 6, 2026 14:47
Enable require(condition, CustomError()) syntax for cleaner error handling.
BREAKING CHANGE: Replace getTradeableOrder with generateOrder/poll split

- Rename getTradeableOrder to generateOrder as single source of truth
- Add generateOrder to IConditionalOrder base interface
- Rename PollTryAtEpoch to PollTryAtTimestamp for clarity
- Remove PollNever error (use OrderNotValid instead)
- Add IConditionalOrderGenerator with poll(), getNextPollTimestamp(), describeOrder()
- Add PollResultCode enum: SUCCESS, PARTIALLY_FILLED, FILLED, WAIT_TIMESTAMP, WAIT_BLOCK, TRY_NEXT_BLOCK, INVALID
- Add PollResult struct with order, scheduling hints, and fill amount
- Clean up documentation comments
- Add verify() that calls generateOrder() and validates hash
- Add poll() that wraps generateOrder() with try/catch for structured results
- Add error decoding for OrderNotValid, PollTryNextBlock, PollTryAtTimestamp, PollTryAtBlock
- Add default getNextPollTimestamp() returning POLL_AT_VALIDTO (0)
- Add default describeOrder() returning generic message
- Define POLL_AT_VALIDTO and POLL_NEVER constants for scheduling hints
- Use string constant INVALID_HASH for error messages
Add filledAmount(bytes calldata orderUid) for querying order fill status.
- Extend BaseConditionalOrder instead of IConditionalOrderGenerator
- Rename getTradeableOrder to generateOrder
- Use require(condition, CustomError()) syntax
- Use string constants for error messages
- Override getNextPollTimestamp() for multi-part scheduling
- Override describeOrder() for human-readable status
- Clean up documentation comments
- Extend BaseConditionalOrder instead of IConditionalOrderGenerator
- Rename getTradeableOrder to generateOrder
- Use require(condition, CustomError()) syntax
- Use string constants for error messages
- Override getNextPollTimestamp() returning POLL_NEVER for single-shot
- Clean up documentation comments
- Extend BaseConditionalOrder instead of IConditionalOrderGenerator
- Rename getTradeableOrder to generateOrder
- Use require(condition, CustomError()) syntax
- Use string constants for error messages
- Override getNextPollTimestamp() returning POLL_NEVER for single-shot
- Clean up documentation comments
- Extend BaseConditionalOrder instead of IConditionalOrderGenerator
- Rename getTradeableOrder to generateOrder
- Use require(condition, CustomError()) syntax
- Use string constants for error messages
- Clean up documentation comments
- Extend BaseConditionalOrder instead of IConditionalOrderGenerator
- Rename getTradeableOrder to generateOrder
- Use require(condition, CustomError()) syntax
- Use string constants for error messages
- Clean up documentation comments
- Refactor isValidSafeSignature to call handler.verify() directly
- Refactor getTradeableOrderWithSignature to use handler.poll()
- Add fill detection via GPv2Settlement.filledAmount()
- Return PARTIALLY_FILLED or FILLED based on order kind (sell/buy)
- Add checkOrder() for quick tradeable status check
- Store settlement contract reference for fill queries
- Use require(condition, CustomError()) syntax
- Clean up documentation comments
Remove unused comments.
- Clean up unused imports and comments
- Update for new interface signatures
- Add test handlers for each error type (OrderNotValid, PollTryNextBlock, etc.)
- Update TestConditionalOrder to extend BaseConditionalOrder
- Rename getTradeableOrder to generateOrder in mocks
- Update for PollResult return type from getTradeableOrderWithSignature
- Test error decoding for OrderNotValid, PollTryNextBlock, PollTryAtTimestamp, PollTryAtBlock
- Test verify() hash validation
- Test that getTradeableOrderWithSignature uses poll() internally
- Fuzz tests for error message propagation
- Rename test_getTradeableOrder_* to test_generateOrder_*
- Update for PollResult return type from getTradeableOrderWithSignature
- Update expected error types (PollTryAtTimestamp vs OrderNotValid)
- Update fuzz bounds for simulate tests
- Clean up documentation comments
- Document settlement path vs polling path separation
- Add PollResultCode semantics table with PARTIALLY_FILLED and FILLED
- Document fill detection via GPv2Settlement
- Add polling path diagram with fill checking flow
- Document nextPollTimestamp semantics
- Add implementation checklist for new order types
Restructure validToBucket() to calculate bucket number first, then multiply.
Same behavior, clearer intent, no lint warning.
Add out/ to .gitignore to exclude Foundry build artifacts.
Introduces a new interface enabling enumeration of all discrete orders
that a conditional order will produce. Useful for analytics, UI previews,
and order lifecycle tracking.

- Cardinality enum: FINITE, BOUNDED, UNBOUNDED
- ManifestInfo struct for high-level order count information
- ManifestEntry struct with order details and validity window
- Paginated getManifestPage() for efficient enumeration
Provides a default single-shot manifest implementation:
- getManifestInfo() returns FINITE with totalOrders: 1
- getManifestPage() wraps generateOrder() for a single entry
- Adds IOrderManifest to supportsInterface()

Order types can override these for multi-part or unbounded orders.
TWAP:
- FINITE cardinality with n parts
- Full pagination support for all TWAP parts
- Consolidated helpers for order construction

StopLoss:
- Custom manifest showing order structure even when strike not reached
- Helper to check strike condition without reverting

GoodAfterTime:
- Sets validFrom to startTime for proper scheduling display

TradeAboveThreshold:
- Shows order structure even when below threshold

PerpetualStableSwap:
- UNBOUNDED cardinality with hasMore always true
- Emit ConditionalOrderRemoved when orders are deauthorized via remove()
- Update contract natspec to reflect ERC-1271 wallet support
27 tests covering:
- BaseConditionalOrder default manifest behaviour
- TWAP manifest with pagination and timing verification
- StopLoss manifest with oracle condition checking
- GoodAfterTime manifest with validFrom handling
- TradeAboveThreshold manifest with threshold states
- PerpetualStableSwap unbounded manifest
- ConditionalOrderRemoved event emission
Updates architecture documentation to reflect:
- ERC-1271 wallet support (not just Safe wallets)
- ERC1271Forwarder integration pattern
- IOrderManifest interface and implementation details
- ConditionalOrderRemoved event

Adds comprehensive breaking changes section documenting all interface
and contract changes from cowprotocol/composable-cow upstream:
- IConditionalOrder: PollTryAtEpoch renamed, PollNever removed
- IConditionalOrderGenerator: getTradeableOrder() replaced by poll()
- ComposableCoW: return type and fill status changes
- BaseConditionalOrder: manifest support added
Copy link
Copy Markdown

@anxolin anxolin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to see you are taking further the learnings from building the indexers to give the composable cow handlers more semantics.

Do you think we can come up with a simpler way to automatically decode the static input? (so UIs and the indexer have an easier time explaining the order)

We have this thing that uses the SDK, but requires you to register the handler.
https://sdk-tools.cow.fi

Comment thread docs/architecture.md
}
```

### PollResultCode Semantics
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the general idea, but It feels we mix some concepts inside PollResultCode

I assume the code is about some human readable message that is nice for debugging.
But for example, what if its PARTIALLY_FILLED our order from last poll but we want to also return WAIT_TIMESTAMP for example?

I understand poll result about the generation of a discrete order from the programmatic order. So I don't get too much SUCCESS , PARTIALLY_FILLED, FILLED, and instead should just be POST_NEW_ORDER or similar.

This way, you either get INVALID or it signals to wait for block/timestamp or it tells you to post an order.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I get FILLED too, as a kind of equivalent to INVALID('already filled'), but I don't get PARTIALLY_FILLED

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PollResultCode does contain additional information, and nominally there is a discrete order that is authorised if the PollResult struct contains a PullResultCode of SUCCESS, PARTIALLY_FILLED, or FILLED.

In these cases, the nextPollTimestamp would then contain the timestamp as to when to next poll the contract for a discrete order. Semantically, type(uint256).max aligns with similar semantics in that setting type(uint32).max is the settlement contract's preferred method of invalidation (nothing can happen past this time as its the end type bounds).

Given this, there may be some duplication between waitUntil and nextPollTimestamp and we could most probably compress these down into the same part of the struct, though having waitUntil sounds nicer, though no strong opinions there.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I still don't get it.

Watch tower will poll, potentially every block.
Each poll will return a PollResultCode. Its possible, that is not time to trade (WAIT_TIMESTAMP/WAIT_BLOCK/TRY_NEXT_BLOCK/INVALID) or its time to trade.

If its time to trade, you are saying you want to return some order data from the handler, but also make that handler responsible of deriving the orderUid and checking in the settlement the traded amounts, so you can decided if its

  • SUCCESS: Untouched order, potentially needs to be created
  • PARTIALLY_FILLED: We can assume it was created. Potentially watch tower doesn't need to do anything
  • FILLED: We can assume it was created. Watch tower needs to stop caring about the order

Is this your idea behind the enum?

Copy link
Copy Markdown
Author

@mfw78 mfw78 Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is to make allowance and present to a possible UI that if the ConditionalOrderParams are indexed, a dapp could subsequently do the poll itself and determine the state of the order, potentially reducing load on indexers and shifting load to RPC endpoints.

And yes, the breakdown in all cases is similar to what you mention with some nuances:

  • SUCCESS: Order has been yielded and should be submitted by the off-chain indexer to the API.
  • PARTIALLY_FILLED: Discrete is already in the order book, as evidenced by being partially filled. No need for the off-chain indexer to do anything for this specific discrete order. Off-chain indexer is to respect nextPollTimestamp which has semantics to indicate if it should do anything, or cease monitoring the ConditionalOrder.
  • FILLED: Same semantics as PARTIALLY_FILLED, just with the difference that the order has been completed filled.

Comment thread docs/architecture.md
### Single-Shot Orders (StopLoss, GoodAfterTime)

```solidity
function generateOrder(...) public view returns (GPv2Order.Data memory) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not worth it, but do you think there's any value on letting a handler generate more than one order per block?
generateOrder could take a index param , and return the order and a boolean indicating if there's more orders to be generated.

This way you call it once, count=0 and if there's more orders (moreOrders=true), you call again with count=1 and so forth

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A simple salt can be used so that multiple orders can be done in the same block. This is supported already at the ComposableCoW level, above the handler.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I get this, these are the params

 struct ConditionalOrderParams {
        IConditionalOrder handler;
        bytes32 salt;
        bytes staticInput;
    }

When we create a single order, we create composableCow.create(..) and give this info. So SALT is given.

What I'm asking here, is, imagine we want to make a contract that in each polling creates 2 orders.
For the same handler/salt/staticinput poll, it will return

  1. Call 1: [order1, hasMoreOrders=true]
  2. Call 2: [order2, hasMoreOrders=false]

This way conditional order has a way to yield more than one order

Copy link
Copy Markdown
Author

@mfw78 mfw78 Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair in that the salt is a given. What is a concrete example of a conditional order type that would yield multiple discrete orders such as this?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not saying we should do it, but assuming each block generates only one order is arbitrary

  • Multi-currency-DCA: Buys you each month automatically a bunch of tokens
  • Simplified AMM? Creates multiple buy and sell orders
  • Go long and take profit: Sell something , and creates a limit order to take profit once the bought token appreciates

Comment thread docs/architecture.md
|-------|---------|
| `0` | Use `order.validTo + 1` as default |
| `> 0` | Poll at this specific timestamp |
| `type(uint256).max` | Final order, stop polling after fill |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not strong preference, but wouldn't be slightly more natural to make

  • 0 --> stop polling
  • 1 --> validTo+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do prefer the semantics of having "stop" being indicated by the "maximum" timestamp (nothing can happen beyond that point of time if that's the time's bounds), ie. with the purpose / point as outlined in the previous comment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was, watch-tower will probably stop watching if the time is in the past. So 0 was saying, don't poll any more. But im fine with yours

Comment thread docs/architecture.md

## Order Type Patterns

### Single-Shot Orders (StopLoss, GoodAfterTime)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make a simpler alternative way for these orders?

It has been once of the biggest criticism of composable cow.
It would be a scape hatch, for example

  • A method in composable cow contract createSingleShotOrder(order)
  • The method emits an event that is picked by composable cow
  • The owner of the order is a random contract which implements 1271
  • This contract doesn't need to do the delegation to composable cow at all
  • Composable cow post the order to the API (if ready), if not, tries on every block

What this allow is to make a contract that creates the order by calling this simple method with the order you want to place. It allows a trivial way to integrate.

I see that someone could spam with this (if we keep trying forever), however the indexer can have some policies to pause polling for some orders after a while. Even if we decide to drop it after 30min is already super useful for quick integrations.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ComposableCoW was never designed inherently to support single-shot, and these were somewhat "wedged" into the design in retrospect. It was designed to support primarily generational conditional orders that generate multiple discrete orders.

I don't think that we should be opening some escape hatch mechanism like this, and perhaps should drive more of a defining line that separates multi-discrete order generators and single-shot. The concern with single-shot has always been with trying to define a nullifier that ensures the single-shot property. For example, StopLoss will continue to generate discrete orders so long as the conditions are met, even if the position has already been sold, and so to handle this, it was often done that there would be some intermediate contract whose job was specifically just to hold the funds for that purpose (this is really a mess, and IMO having specific contracts spun up for specific orders such as this is more indicative of an architectural smell as opposed to a sound system).

I would think that there may be scope available for the single-shot conditional order mechanism to be refined / clarified with a combination of indexing work + generalised wrappers, so as to be able to then ensure that some requisite post-hook has run, adjusting state to nullify the single-shot conditional order, though I would determine generalised wrapper implementation for such to be out-of-scope on the current grant (but indexing / architectural design to be within scope).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I got all this.

The point is, currently is super complex for a smart contract to create an order.

They need to use ComposableCoW, which has a lot of indirection built in and people struggle to understand.

So for the simpler cases, of a SC creating an order, and a watch tower picking it, would be nice to have a contract that receives the params, emits the event, and someone creates the order for them.

It doesn't need to be in Composable CoW it can be an independent contract, I just thought watch-tower could index and post order for both flows.

If this is out of scope, fine, I just bring it up as it has been a feedback, and if you are modifying the contract, which will require re-audit and has breaking changes, might be worth it to add this alternative simpler flow.

Comment thread docs/architecture.md
return buildPartOrder(currentPart);
}

function getNextPollTimestamp(...) external view returns (uint256) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i love moving the polling logic to the handler. Not sure people will put the effort into optimising for this in the contract, but it really makes the indexers simpler.

Comment thread docs/architecture.md
| Cardinality | Description | Example |
|-------------|-------------|---------|
| `FINITE` | Known fixed number of orders | TWAP with n parts |
| `BOUNDED` | Upper bound known; actual count is dynamic | Future order types |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't get exactly what you mean? can you give one example

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cardinality defines the number in a set. Let's consider some hypothetical "range trading" conditional order that trades for side-ways pairs. It may attempt to do so every n period, but only if certain conditions are established (price at certain range), for some time t. In which case, the upper bound (cardinality) for this conditional order may be t / n, but the price case may not always be met, so it may be less than t / n.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe what confuses me is that BOUNDED is by defiition FINITE.

@mfw78
Copy link
Copy Markdown
Author

mfw78 commented Feb 16, 2026

Do you think we can come up with a simpler way to automatically decode the static input? (so UIs and the indexer have an easier time explaining the order)

We have this thing that uses the SDK, but requires you to register the handler. https://sdk-tools.cow.fi

So, I suppose we could add a 'Metadata' abstract contract interface, but this would present only a string-based representation of the conditional order. Nonetheless, this is the nature of ABI and IMO SDK interfaces coupled with dynamic discovery method to retrieve some application specific module that is capable of decoding (this would then allow, for example, a handler to register via ENS the location of their decoder) is the way to go. Such design is non-trivial, but could be included from an architectural perspective so that some discovery mechanism was able to be implemented at a later date.

@anxolin
Copy link
Copy Markdown

anxolin commented Feb 16, 2026

Do you think we can come up with a simpler way to automatically decode the static input? (so UIs and the indexer have an easier time explaining the order)
We have this thing that uses the SDK, but requires you to register the handler. https://sdk-tools.cow.fi

So, I suppose we could add a 'Metadata' abstract contract interface, but this would present only a string-based representation of the conditional order. Nonetheless, this is the nature of ABI and IMO SDK interfaces coupled with dynamic discovery method to retrieve some application specific module that is capable of decoding (this would then allow, for example, a handler to register via ENS the location of their decoder) is the way to go. Such design is non-trivial, but could be included from an architectural perspective so that some discovery mechanism was able to be implemented at a later date.

What about we force handlers to implememt:

function staticInputAbi() external pure returns (string memory);

This way TWAP can return the string:

TWAPOrder(address sellToken,address buyToken,address receiver,uint256 partSellAmount, ...)

Any web3 lib could use this and the static input and make sense of it

@mfw78
Copy link
Copy Markdown
Author

mfw78 commented Feb 17, 2026

function staticInputAbi() external pure returns (string memory);

This would have to take a bytes parameter as well yes? ie.:

function staticInputAbi(bytes calldata) external pure returns (string memory);

The interface would make no enforcement at a smart contract level as to what the string itself would return, other than that the developer should "convey a human readable understanding of the static input".

Comment thread docs/architecture.md

## Order Type Patterns

### Single-Shot Orders (StopLoss, GoodAfterTime)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you confirm if the single shot order enforcement is more flexible or if the same pattern will be used -- handler must return a constant order?

In my opinion single-shot orders handlers should be more easier / more flexible to create. Stop loss is a good example that you must hard-code the entire order to make it work (so you can't calculate the amounts based on current price + slippage for example). The current interface limits the flexibility of single shot orders a lot in my understanding.

I don't have full context on how hard would be possible to create an easier way to do it with this new interface. Since the pooling and verifier interfaces changed, my understand is that this could be solved by invalidating the composable cow after its verification. This would only work for fill or kill orders but it still looks like a good feature from my point of view.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have full context on how hard would be possible to create an easier way to do it with this new interface. Since the pooling and verifier interfaces changed, my understand is that this could be solved by invalidating the composable cow after its verification. This would only work for fill or kill orders but it still looks like a good feature from my point of view.

As isValidSignature is view only, it is not possible to modify any state after its verification. The only method to modify any state is by reference to a post-hook, which then runs into authorisation / griefing attack problems (anyone can execute the post-hook that does the voiding action from the HooksTrampoline).

Essentially I think that with the difficulty associated with this, it's more than likely that this kind of design is an anti-pattern when it comes to CoW Protocol in its current incarnation. A much simpler solution, albeit one that requires the use of gas, is to have a conditional order that is triggered via account abstraction or similar, which does setPreSignature and then subsequently voids the conditional order atomically.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As isValidSignature is view only, it is not possible to modify any state after its verification. The only method to modify any state is by reference to a post-hook, which then runs into authorisation / griefing attack problems (anyone can execute the post-hook that does the voiding action from the HooksTrampoline).

This could be convined with Generalise Wrappers to make it work.

We add a wrapper hint to a contract that, marks the order as traded after the settlement. The composable cow handler, only allows to trade if we haven't traded a similar order.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this make sense?

image

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's a second sister order generated by the handler:

  • If the settlement calls the wrapper (as instructed) --> Reverts
  • If it skips the wrapper, the order will be unsigned
image

Copy link
Copy Markdown
Author

@mfw78 mfw78 Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the advent of the wrappers, does this create any technical debt on the indexing methods that have been proposed in this PR?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. The indexer would pick order1 post it to the API, while in-flight, will pick a second order order2, because the 1st one is not executed, it will post it to the API too.

The API accept both, they both are accepted by the contract.

The first one executing, will automatically invalidate the other. The order will remain in the orderbook until expiration.

Comment thread src/ComposableCoW.sol
// the slot to the conditional order, such that there is a guarantee or data integrity

// Set the cabinet slot
cabinet[msg.sender][hash(params)] = factory.getValue(data);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is set after the create call this create a problem on getting this value on indexers. The problem is basically that on the event emition moment, the variable is not set yet.

I see a few options for improving this:

  • Create a specific event for cabinet setting.
  • Cabinet is set before the create call
  • Create a new type of event for create with context that includes the value that the cabinet is being set.

All three options should work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants