Skip to main content

Escrow Flow (pt 3 - Composing Demands)

The logical arbiter contracts AnyArbiter and AllArbiter can be used to compose demands. For example, you could demand that

  • a task can only be completed by a particular account, and is validated by a trusted third party
  • a task is completed before a particular deadline, and validated by any of a list of trusted third parties
  • uptime for a server provision is above a certain threshold, and is actually the expected service

Using logical arbiters

You can use AllArbiter to demand multiple conditions at the same time. For example, that your task is completed by a particular individual before a deadline, and validated by a third party.

// Example: Require job completed by specific recipient AND validated by oracle
bytes memory recipientDemand = abi.encode(
RecipientArbiter.DemandData({
recipient: bob // Only Bob can fulfill this
})
);

bytes memory oracleDemand = abi.encode(
TrustedOracleArbiter.DemandData({
oracle: charlie, // Charlie validates the result
data: abi.encode("complete the analysis task")
})
);

// Compose with AllArbiter - both conditions must be met
bytes memory composedDemand = abi.encode(
AllArbiter.DemandData({
arbiters: [address(recipientArbiter), address(trustedOracleArbiter)],
demands: [recipientDemand, oracleDemand]
})
);

// Deposit escrow with the composed demand
IERC20(erc20Token).approve(address(erc20EscrowObligation), 100 * 10**18);
bytes32 escrowUid = erc20EscrowObligation.doObligation(
ERC20EscrowObligation.ObligationData({
token: erc20Token,
amount: 100 * 10**18,
arbiter: address(allArbiter),
demand: composedDemand
}),
block.timestamp + 86400
);

You can use AnyArbiter when multiple alternative conditions can be considered valid. For example, if you accept a decision from any of a list of trusted third party oracles, or if there are different proof mechanisms that equally ensure the validity of a result.

// Example: Accept validation from ANY of multiple trusted oracles
bytes memory oracle1Demand = abi.encode(
TrustedOracleArbiter.DemandData({
oracle: charlie,
data: abi.encode("verify computation result")
})
);

bytes memory oracle2Demand = abi.encode(
TrustedOracleArbiter.DemandData({
oracle: dave,
data: abi.encode("verify computation result")
})
);

bytes memory oracle3Demand = abi.encode(
TrustedOracleArbiter.DemandData({
oracle: eve,
data: abi.encode("verify computation result")
})
);

// Compose with AnyArbiter - any one oracle can validate
bytes memory composedDemand = abi.encode(
AnyArbiter.DemandData({
arbiters: [
address(trustedOracleArbiter),
address(trustedOracleArbiter),
address(trustedOracleArbiter)
],
demands: [oracle1Demand, oracle2Demand, oracle3Demand]
})
);

// Create escrow with flexible oracle validation
bytes32 escrowUid = erc20EscrowObligation.doObligation(
ERC20EscrowObligation.ObligationData({
token: erc20Token,
amount: 50 * 10**18,
arbiter: address(anyArbiter),
demand: composedDemand
}),
block.timestamp + 7200 // 2 hour expiration
);

Logical arbiters can be stacked. For example, you could demand that a job is completed before a deadline by a particular party, and validated by any of a list of trusted oracles.

// Example: Complex composition - deadline AND recipient AND (oracle1 OR oracle2)
bytes memory deadlineDemand = abi.encode(
TimeBeforeArbiter.DemandData({
time: uint64(block.timestamp + 3600) // Must complete within 1 hour
})
);

bytes memory recipientDemand = abi.encode(
RecipientArbiter.DemandData({
recipient: bob // Must be fulfilled by Bob
})
);

// Create the OR condition for oracles
bytes memory oracle1Demand = abi.encode(
TrustedOracleArbiter.DemandData({
oracle: charlie,
data: abi.encode("validate result")
})
);

bytes memory oracle2Demand = abi.encode(
TrustedOracleArbiter.DemandData({
oracle: dave,
data: abi.encode("validate result")
})
);

bytes memory oracleOrDemand = abi.encode(
AnyArbiter.DemandData({
arbiters: [address(trustedOracleArbiter), address(trustedOracleArbiter)],
demands: [oracle1Demand, oracle2Demand]
})
);

// Combine all conditions with AllArbiter
bytes memory finalDemand = abi.encode(
AllArbiter.DemandData({
arbiters: [
address(timeBeforeArbiter),
address(recipientArbiter),
address(anyArbiter)
],
demands: [
deadlineDemand,
recipientDemand,
oracleOrDemand
]
})
);

// Create the escrow with nested logical arbiters
bytes32 escrowUid = erc20EscrowObligation.doObligation(
ERC20EscrowObligation.ObligationData({
token: erc20Token,
amount: 200 * 10**18,
arbiter: address(allArbiter),
demand: finalDemand
}),
block.timestamp + 7200 // 2 hour overall expiration
);

Parsing composed demands

The CLI and SDKs provide built-in support for recursively parsing composed demands. The parsers automatically detect the arbiter type and decode each sub-demand appropriately.

// Example: Validate that on-chain demand matches expected structure
function validateDemand(
bytes memory onChainDemand,
bytes memory expectedDemand
) public pure returns (bool) {
// Simply check if the demands match
return keccak256(onChainDemand) == keccak256(expectedDemand);
}

// Or decode and validate specific parts
function parseAllArbiterDemand(bytes memory demand)
public
pure
returns (address[] memory arbiters, bytes[] memory demands)
{
AllArbiter.DemandData memory demandData = abi.decode(
demand,
(AllArbiter.DemandData)
);
return (demandData.arbiters, demandData.demands);
}

// Example usage
(address[] memory arbiters, bytes[] memory subDemands) = parseAllArbiterDemand(composedDemand);
require(arbiters[0] == address(timeBeforeArbiter), "First arbiter must be TimeBeforeArbiter");
require(arbiters[1] == address(recipientArbiter), "Second arbiter must be RecipientArbiter");

For custom arbiters not included in the SDK's built-in decoders, you can extend the decoder registry (TypeScript) or manually decode the raw bytes using ABI decoding.

Why there's no NotArbiter

You might expect a NotArbiter to complement AllArbiter and AnyArbiter, allowing you to negate any arbiter's logic. However, Alkahest intentionally does not include a NotArbiter due to fundamental security concerns.

The revert problem

Arbiters typically signal failure by reverting rather than returning false. This allows them to provide meaningful error messages explaining why arbitration failed (e.g., "recipient mismatch" or "deadline passed"). A NotArbiter would need to catch these reverts and invert the result, but this creates a critical ambiguity:

A NotArbiter cannot distinguish between:

  • Logical reversions - The arbiter intentionally reverted because its condition wasn't met (e.g., wrong recipient)
  • Execution reversions - The call failed for technical reasons (e.g., out of gas, invalid calldata, contract bug)

If a NotArbiter treated all reverts as "condition not met" and returned success, it would incorrectly pass arbitration when calls fail due to out-of-gas attacks, malformed data, or other execution errors. This could allow attackers to bypass security checks.

What to do instead

Use built-in complement arbiters. Most arbiters with useful complements already have them:

ArbiterComplement
TimeBeforeArbiterTimeAfterArbiter
ExpirationTimeBeforeArbiterExpirationTimeAfterArbiter

Write a standalone inverting arbiter. Fork or rewrite the arbiter with inverted logic. This is the safest approach since you explicitly implement the complement:

// Example: Standalone arbiter that inverts RecipientArbiter logic
// (allows anyone EXCEPT a specific address)
contract NotRecipientArbiter is IArbiter {
struct DemandData {
address excludedRecipient;
}

function checkStatement(
Attestation memory statement,
bytes memory demand,
bytes32 /* counteroffer */
) external pure returns (bool) {
DemandData memory data = abi.decode(demand, (DemandData));
// Explicitly check the inverse condition
return statement.recipient != data.excludedRecipient;
}
}

Subcall the original arbiter with explicit error handling. If you want to wrap an existing arbiter, you must explicitly handle its known revert reasons rather than catching all reverts:

// Example: Inverting arbiter that subcalls the original
contract InvertingArbiter is IArbiter {
IArbiter public immutable underlying;

constructor(IArbiter _underlying) {
underlying = _underlying;
}

function checkStatement(
Attestation memory statement,
bytes memory demand,
bytes32 counteroffer
) external view returns (bool) {
try underlying.checkStatement(statement, demand, counteroffer) returns (bool result) {
return !result;
} catch (bytes memory reason) {
// CRITICAL: Only catch KNOWN logical revert reasons
// Unknown reverts (OOG, invalid data) must propagate
bytes4 selector = bytes4(reason);
if (selector == RecipientMismatch.selector) {
return true; // Logical failure -> inverted success
}
if (selector == DeadlinePassed.selector) {
return true; // Logical failure -> inverted success
}
// Unknown error - do NOT treat as success, propagate it
assembly {
revert(add(reason, 32), mload(reason))
}
}
}
}

This approach requires you to enumerate every logical revert reason the underlying arbiter can produce. Unknown reverts (out of gas, invalid calldata, bugs) propagate rather than being incorrectly treated as "condition not met."