Skip to content

feat: Timelock component - #47

Open
pepebndc wants to merge 106 commits into
mainfrom
timelock-proposal
Open

pepebndc wants to merge 106 commits into
mainfrom
timelock-proposal

Conversation

@pepebndc

@pepebndc pepebndc commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Resolves #31

Summary

Adds the Timelock component as two packages, split as Pausable is:

  • openzeppelin-api-timelock-v1, module OpenZeppelin.Api.TimelockV1: the frozen interfaces Timelock, Operation, and Timelocked, their views, TimelockConfig, Pending, and DropReason. The package defines no templates and holds no logic.
  • openzeppelin-timelock-v1, module OpenZeppelin.TimelockV1: the lifecycle functions, the schedule functions, the policy validation, the pending-list functions, the time guards, and the failure statuses. It upgrades independently of the frozen package.

The consumer writes the timelock template, one template for each operation kind, and the schedule choices. The timelock implements Timelock. It holds the delay policy and the pending list, and its apply method dispatches on the operation's template with fromInterface. The interface choices Timelock_Apply and Timelock_Drop call the applyImpl and dropImpl methods, which the consumer implements with one call to applyOperation or dropOperation. These functions check pending membership, shared authority, executor or canceller permission, and the ledger-time bounds. Then they archive the operation and call the consumer's method. The Splice token standard uses the same pattern, so a fix to a check ships in the function package and reaches consumers through SCU.

Design page: Timelock (Design & Development specs, with the research report and the design document as child pages).

Changes

  • packages/security/api-timelock-v1: the interfaces, the views, the applyImpl and dropImpl methods, and Timelock_Apply, Timelock_Drop, Operation_Execute, Operation_Cancel, and Operation_Cleanup.
  • packages/security/timelock-v1: applyOperation, dropOperation, scheduleAt, scheduleAfter, isValidConfig, requireValidConfig, addPending, takePending, requireReady, requireExpired, isReadyAt, isExpiredAt, and ten failure statuses under openzeppelin.com/timelock-. The failure statuses and the private checks live in OpenZeppelin.TimelockV1.Internal, and the public module re-exports the failure statuses.
  • test/api-timelock-v1-test (49 Daml Script tests): the lifecycle, the pending list, direct calls, false targets, methods that return a wrong pending list, operations with extra signatories, and policy changes.
  • test/timelock-v1-test (16 tests): the schedule functions, the policy validation, the pending-list functions, and the guards.
  • examples/timelock/treasury: a timelock, a governed config, and a treasury on separate contracts. Payments do not contend with governance. The example has a proposer, an executor, cancellation, expiry cleanup, a recovery choice for stale entries, and self-administration of the delay policy.
  • Documentation: both package READMEs, the root README.md, packages/README.md, examples/README.md, ARCHITECTURE.md, AGENTS.md, CONTRIBUTING.md, CHANGELOG.md, and multi-package.yaml.

Canton design points

  • An operation is a typed contract that the timelock's signatories sign. The timelock's apply reads its parameters through interface dispatch.
  • The pending list binds an operation to the timelock that scheduled it. An operation created outside a schedule choice has no effect.
  • The guards use isLedgerTimeGE and isLedgerTimeLT, not getTime, so an apply transaction prepared before readyAt stays valid once it passes.
  • No predecessor field and no batch DSL. Choice bodies are atomic, and a precondition on the governed state orders operations.

Security review

Two independent reviews ran against 6601abe. Neither found a critical or high issue in the library. These commits address their findings:

  • c836383: the successor must hold exactly the reduced pending list (ePendingMismatch).
  • a916893: an operation with a signatory that the timelock does not have is refused (eInvalidAuthority).
  • 94dc171: Treasury_Pay compares the full signatory sets, and the docs state that the delay does not bind a sole signatory.
  • 07638d7: a recovery choice for pending entries of directly archived operations.
  • 6968a9e, b069305, a8f91a3, 9b2e1e3: documentation of the gracePeriod tolerance bound, package upgrades, and policy-change scope, plus tests.

Verification

dpm build --all
scripts/check.sh
scripts/check-lint.sh
scripts/check-examples.sh
scripts/check-coverage.sh
DAML_PACKAGE=test/api-timelock-v1-test dpm test --all --show-coverage
DAML_PACKAGE=test/timelock-v1-test dpm test --all --show-coverage
DAML_PACKAGE=examples/timelock/treasury-test dpm test --all

All pass locally on SDK 3.5.8.

Open for review

  • Keep scheduleAfter, the one export that reads getTime?
  • Carry a minSetback protection in the library, or leave it to the consumer?
  • Recommend a default gracePeriod?

🤖 Generated with Claude Code

0xNeshi added 30 commits August 25, 2026 14:11
PausableView no longer carries a pauser. The core interface reports the
flag and grants no authority, so a template can implement it whatever
authorizes its switch.

PausableAdmin requires Pausable and owns the switch for the single-party
case: setPausedImpl, PausableAdmin_Pause, PausableAdmin_Unpause. The
compiler rejects an implementation that omits the core.

A single Party cannot express a role grant, an M-of-N approval, or a
timelock, and a Daml controller expression is pure, so it cannot fetch a
credential to decide who may act. Freezing pauser into the shared view
would have forced those consumers to publish a false claim about who
holds the switch, and to ship a live second path to it. They now
implement Pausable alone and write their own pause choice.

Adds a RoleVault fixture and a test covering that path.
Answers the six open questions in place, each beside the question it
settles, and fills Dev Notes with the three decisions that depart from
the Recommendation: the view carries paused only, the guards do not
evaluate ledger time, and the separate-contract shape is not shipped.

Also notes the PausableAdmin split, which the research did not
anticipate, and the controller-purity constraint behind it.

The Recommendation is left as written. It records what the research
concluded, not what was later chosen.
Drop PausableAdmin, PausableAdminView, PausableAdmin_Pause, and
PausableAdmin_Unpause. Pausable now carries the flag, the getter,
setPausedImpl, and the new top-level pause/unpause, and it names no pause
authority. Every consumer writes the choice that decides who may flip the
switch, matching _pause() and _unpause() being internal in Solidity.

The add-on saved a single-party consumer four lines and cost a pauser field
in a frozen view, where a placeholder value publishes a false claim about who
holds the switch. Moving setPausedImpl onto the core interface makes
pause/unpause carry the guard for every authority model rather than only the
single-party one.

Two hazards the add-on used to absorb are now documented on pause, on the
interface, and in the README: the calling choice must be consuming, since
pause creates the successor but archives nothing; and setPausedImpl is a
public method whose create needs the implementing template's signatory
authority, which is what stops an unrelated contract from flipping the flag.

Test fixtures show both authority models as consumer-written choices, and two
new tests pin the archival behaviour and the authority limit.
openzeppelin-pausable-v1 and its test package are superseded by
openzeppelin-pausable-api-v1 under packages/. Drop both from the workspace and
from every reference to them: the multi-package package list, the component
tables in the root and experiments READMEs, the root repository-layout block,
their CHANGELOG section, and their lint and test commands in the AGENTS.md
validation list. The research artifact's Out of Scope bullet named both
prototypes by path; it now records that they were removed and superseded.

Also add packages/security/pausable-api-v1 and test/pausable-api-v1 to the
AGENTS.md validation list, which never listed them. The CI discovery scripts
already cover both.
Stage 2 of the pipeline, built on artifacts/01-research.md and produced
greenfield: the prototype packages already on disk were not treated as a
baseline.

The package is a shareable component - one frozen interface, a one-field view,
and pure guards and guarded flips, with no templates. The flag lives on the
consumer's own template, so the guard reads the contract the gated choice is
already exercising and cannot be handed a substituted or omitted switch.

Records fifteen design decisions with their reasoning, including the two
alternatives that were evaluated and rejected: pausing by archiving the
contract and restoring it on unpause, and a type class for the write path in
place of an interface method. Every shape discussed was compiled before being
chosen or dropped.
Interface surface:

- setPausedImpl becomes a pure setPaused : Bool -> Pausable. The flips now
  create the successor, so the method archives nothing and returns a value.
- Drop the Pausable_Paused read choice. Its flexible controller made it a
  self-divulgence primitive - informees of an exercise node see the input
  contract - frozen into every adopter and unremovable. isPaused answers the
  same question purely, and an off-ledger reader uses the interface view.
- pause and unpause return ContractId t instead of ContractId Pausable, so no
  coercion appears at the consumer's call site.
- Add pauseWith and unpauseWith, so a registry can set its own pauseInfo fields
  in the same transaction as the flip instead of bypassing the guard.
- Add eFlagNotApplied, asserted after both steps, so an implementation that
  ignores the flag argument or a lambda that clobbers it fails loudly rather
  than yielding a pause that silently does not pause.
- Add eImplementerTypeMismatch, reported through fromSomeNote, for an
  implementation that returns another implementer's template.

Fourteen exports. Because pause now needs HasCreate on a concrete template, a
stranger holding a ContractId Pausable can no longer even compile a flip; that
path previously compiled and failed at runtime for want of authority. Reads stay
generic: isPaused and whenNotPaused both accept a bare Pausable.

Tests cover the new surface with fixtures that break the setPaused law on
purpose, a nonconsuming flip that demonstrates the one obligation the library
cannot enforce, and the sibling-field flips. All 21 scripts pass.
The design decisions log enumerated the three shapes that were built and
compiled as (A), (B), (D), skipping (C). Rename the third to (C) and update
the three later references to it in decisions 6 and 7.
Two standalone projects under examples/pausable/, each integrating the released
openzeppelin-pausable-api-v1 DAR through data-dependencies and carrying a Daml
Script that runs its lifecycle. They are the worked examples from the design
artifact, made executable.

vault is the minimal adoption: the three-line interface instance, whenNotPaused
on a gated choice, whenPaused on a recovery path, an unguarded escape hatch so a
pause does not trap the owner's funds, and the two choices that name the pause
authority. Its script walks the withdrawal, the pause, the refused withdrawal,
the refused double pause, the recovery path, and the return to normal operation.

registry is the sibling-field case: pauseWith and unpauseWith record and clear
CIP-0112 pauseInfo fields in the same transaction as the flip, so the metadata
response comes from one on-ledger contract while PausableView still carries the
flag alone.

scripts/check-examples.sh discovers every example, verifies it data-depends on a
production DAR, and runs its script; ci.yml calls it after the lint step, so the
examples are integration evidence rather than untested prose. Directories under
examples/ group by component and never appear in a package name, matching the
convention packages/ and experiments/ already follow.
The README still described the pre-design prototype. It listed Pausable_Paused
and setPausedImpl, neither of which exists, and its interface instance showed
setPausedImpl as an Update action creating a contract rather than the pure
setPaused. Both example choices returned ContractId Pausable, which does not
type check against pause now that the flips return the consumer's own template
id. pauseWith, unpauseWith, and the four exported error constants were missing
entirely.

Also state why the interface declares no choice and how a reader gets the flag
without one, and split the field-preservation caveat: the flag is checked and a
flip that fails to apply it raises eFlagNotApplied, while the other fields are
not checked and cannot be.

Link the two consumer examples.
`submitWithDisclosures` and `submitWithDisclosuresMustFail` are legacy Daml
Script API. Build the disclosure into `SubmitOptions` with `actAs` and
`disclose` instead.
The lambda-taking flip forced consumers to wrap a record update in a
function. markPaused and markUnpaused run the guard and the flip and hand
back the template value, so a choice writes an ordinary create with the
sibling fields set, with no intermediate contract.
A leading doc comment on an interface method is a parse error for the
docs tool, so the module produced no API documentation. The setPaused
documentation is now a trailing comment, which the tool renders.
Vault_RedeemToAdmin zeroed the balance and moved nothing, which
contradicted the documentation calling it an escape hatch for the
owner's funds. Vault_Redeem and Vault_EmergencyDrain now create a Payout
to the owner and to the admin respectively.
pepebndc and others added 5 commits September 23, 2026 11:26
Open cleanup lets any party choose a ledger time at expiresAt while the
record time is up to the tolerance T earlier. The Time on Canton section,
the TimelockConfig field comment, and the caveats state that the window
before the earliest cleanup is at least gracePeriod - T, and that
gracePeriod must be well above T.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An operation keeps the schedule it received, so a stricter policy does
not reach it, and executors choose the order of ready operations. The
README states both, and a test shows an operation executing at its
original readyAt after minDelay rises to 30 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…limits

A test runs Timelock_Apply and isReadyAt at both edges of readyAt and
expiresAt, so the frozen copies and the public guards cannot drift
apart. Another test shows that a zero-delay policy makes the next
operation immediate. The READMEs state that the frozen copies cannot
take a fix, what ePendingMismatch can and cannot detect, and that the
pending list has no size bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/security/api-timelock-v1/daml/OpenZeppelin/Api/TimelockV1.daml Outdated
pepebndc and others added 5 commits September 24, 2026 10:29
## Summary

This PR applies the findings of an independent two-model review (Claude
and Codex) of #46 at ffd9093. It targets `pausable-proposal`, so it can
merge into #46 as one unit. Each finding is one commit, and each finding
has an inline comment on #46 that links to its commit.

Neither production package changes behavior. The changes are
documentation, one new example, example fixes, and tests. Two commits
change doc comments in `OpenZeppelin.Api.PausableV1` (ce25c47, 343e374),
which changes the source of the frozen package before its release.

## Changes

| Finding | Severity | Commit | Change |
|---|---|---|---|
| F1 | Medium | ce25c47 | The API module header and both READMEs state
that the signatories can reset the flag by archive and create, outside
the flip choice. A test shows the reset. |
| F2 | Medium | d63b4f7 | The API README and `ARCHITECTURE.md` name
`-Wno-template-has-new-interface-instance`, which the SCU check needs
for a retrofit. A new retrofit example builds versions 1.0.0 and 1.1.0
of one package, with tests. |
| F3 | Low | 60ea95a | The API README describes old package versions
after a retrofit. A test shows that an exercise of the old template runs
the new version. |
| F4 | Low | 236cb42 | The `pausable-v1` README states that a guard fix
reaches only new consumer versions. |
| F5 | Low | 343e374 | The `Pausable` doc comment lists the items that
`AGENTS.md` requires. The README describes the implicit `Archive`
choice. A test covers it. |
| F6 | Low | 71b34c4 | The docs state that the guard runs before the
state change and that the first failing check sets the error. A test
covers the credential case. |
| F7 | Low | 2ded6f5 | The API README describes the pause interval
across successor contracts. |
| F8 | Info | 18b7cd6 | The vetting statements cover every participant
that vets the consumer package. |
| F9 | Info | fc32b19 | The registry example adds
`Registry_UpdatePauseInfo`, guarded with `whenPaused`. |
| F10 | Low | 5d65b5b | The root README lists the three experimental
components. |
| F11 | Info | afeadef | The `pausable-v1` README states that
`.Internal` is not public API. |
| F12 | Info | 21cc9ba | The migration docs state that the migration
copies the flag. |
| F13 | Info | 5308b5f | `Vault_Withdraw` pays the amount to the owner
as a `Payout`. |
| Tests | | 2628f8b | Flag persistence through redemption and drain,
example authority, the guard order on an empty entry, repeated flips, a
nonconsuming flip that archives `self`, a nonconsuming gated choice, and
guards on an interface value. |

## Verification

```sh
dpm build --all
scripts/check.sh
scripts/check-lint.sh
scripts/check-examples.sh
scripts/check-coverage.sh
```

All pass locally on SDK 3.4.11. Test counts: `test/api-pausable-v1` 5,
`test/pausable-v1` 26, `vault-test` 14, `registry-test` 12,
`retrofit-test` 3.

## Caveats

- F3: in Daml Script, an exercise that names the 1.0.0 template runs
version 1.1.0, so the guard applies. The downgrade refusal for a
submission that pins 1.0.0 through package preference comes from the
reviewer's test and from the SCU rules. This PR has no test for it,
because Daml Script needs the package ID of 1.0.0 for that.
- `examples/pausable/retrofit-v1-0` data-depends on
`openzeppelin-api-pausable-v1` without using it, because
`scripts/check-examples.sh` requires a production DAR. The package sets
`-Wno-unused-dependency` for that reason.
- There is no Ledger API test of the `DAML_FAILURE` error id.
`scripts/check-sandbox.sh` covers only the token package.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Nenad <xinef.it@gmail.com>
Signed-off-by: Pepe Blasco <pepeblascondc@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Nenad <nenad.misic@openzeppelin.com>
Co-authored-by: Nenad <xinef.it@gmail.com>
…package

Timelock_Apply and Timelock_Drop now call the applyImpl and dropImpl
methods. The consumer implements them with applyOperation and
dropOperation from OpenZeppelin.TimelockV1, which run the checks that
the frozen choice bodies ran. A fix to a check is now a new version of
the function package, and a consumer picks it up through SCU.

The failure statuses and the private checks live in
OpenZeppelin.TimelockV1.Internal, and the public module re-exports the
failure statuses. The duplicate guards and the test that pinned them to
each other are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pepebndc pepebndc linked an issue Sep 24, 2026 that may be closed by this pull request
Base automatically changed from pausable-proposal to main September 25, 2026 14:22
Timelock_Apply and Timelock_Drop are nonconsuming, and applyOperation and
dropOperation archive the timelock before they call apply or unschedule.
A consuming choice informs the timelock's observers of every action in
apply. The observers now see only the archive and the successor.
Comment thread ARCHITECTURE.md
Comment thread AGENTS.md Outdated
Comment thread packages/security/api-timelock-v1/README.md Outdated
Comment thread packages/security/timelock-v1/README.md
… its check

The timelock-v1 README states that a submission can select the old
version of the consumer package while it stays vetted, so a security fix
needs the old version unvetted. The treasury example test makes admin an
observer of the foreign config, so the fetch succeeds and the signatory
check refuses the config.
0xNeshi and others added 2 commits October 2, 2026 12:47
**This is just a partial review of #47 .** 

## Changes

### Cancellers do not bound direct archives
- `packages/security/api-timelock-v1/README.md` now states that
`cancellers` does not limit who can remove an operation, because its
signatories can always call `Archive` directly. It also says to use the
timelock's full signatory set if no single signatory may remove an
operation alone.
*Why:* Daml always gives signatories an `Archive` choice, so integrators
need to know about it and size the signatory set themselves.

### `ARCHITECTURE.md`
- Moves the "choice body calls a method that calls a library function"
rationale out of the Timelock section into a general section, with a
link to the Splice `TransferFactory_Transfer` example and a pointer to
`Checked`.
*Why:* the pattern applies to any component with interfaces, not only
Timelock.
- Removes Timelock paragraphs that repeated package `README.md` content:
package contents, lifecycle authority, and the three-contract
integration layout.
*Why:* `ARCHITECTURE.md` holds design rationale. Usage and behavior
belong in the package `README.md` files.
- Drops the Pausable and Timelock state examples from the migration
paragraph and formats `Pausable` and `pause` as code.
*Why:* the migration paragraph applies to any contract state, so the
component-specific examples were not needed.
Rename the Timelock test packages to the -test directory layout, move
the Timelock packages to SDK 3.5.8, and add the treasury example README
and an archive test that the example coverage gate requires.
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.

[M2] Library — Timelock

4 participants