Skip to content

Simplified our current CIP112 implementation and building the RAD. - #45

Open
igingu wants to merge 14 commits into
mainfrom
igingu-cip112-simplification-and-rad
Open

igingu wants to merge 14 commits into
mainfrom
igingu-cip112-simplification-and-rad

Conversation

@igingu

@igingu igingu commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@igingu
igingu marked this pull request as ready for review September 22, 2026 20:43
@igingu
igingu requested a review from raynaudoe September 22, 2026 20:43

@pepebndc pepebndc left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice work on this. The core is solid

I ran the suite locally at 045956b (SDK 3.4.11). check.sh, dpm build --all, check-coverage.sh (67 tests) and check-sandbox.sh all pass. I then wrote scratch Daml Script tests for the points below, so the inline comments include what they printed. I can share the test module.

Inline, roughly by priority:

  1. SettleBatch accepts non-TokenAllocation contracts (critical).
  2. The same pattern in TokenRules_ExpireAllocations.
  3. Direct Allocation_Settle skips exact cover.
  4. The vendored DARs conflict with the official Splice DARs.
  5. Owner-only calls on provider accounts (CIP-112 4.3.2 MUST).
  6. OwnerUnlock emits no event (CIP-112 MUST).
  7. The V1 guidance conflicts with CIP-112 5.1.
  8. There are no enforceable extension points.

Two small things outside the code:

  • The INFO -, QUESTION - and REVISIT annotations need to go before merge. The QUESTION in transferImpl needs an answer.

All of this is open for discussion. Some points are design trade-offs, so push back where you see it differently.

Comment thread packages/token/tokenCIP112-v1/daml/OpenZeppelin/TokenCIP112V1/Registry.daml Outdated
Comment thread packages/token/tokenCIP112-v1/daml/OpenZeppelin/TokenCIP112V1/Allocation.daml Outdated
Comment thread packages/token/tokenCIP112-v1/README.md Outdated
@@ -287,11 +193,20 @@ allocateImpl rules arg = do
authorizer = allocation.authorizer
checkActors arg.actors [accountParties admin authorizer]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Owner-only calls on provider accounts (CIP-112 4.3.2 MUST)

The owner MUST be able to call the TransferFactory_Transfer and AllocationFactory_Allocate choices, but it is left open to the registries whether this directly creates an Allocation or executes the transfer, or whether this only creates an AllocationInstruction or TransferInstruction which the provider needs to accept.

This check (and Transfer.daml:132) requires owner + provider to initiate. In my test the owner-only calls fail and owner + provider succeeds. Joint authorization of the effect is fine; the gap is the owner's initiation path.

Options:

  • Return a pending instruction that the provider accepts.
  • Use AccountConfig-style delegation, as TestTokenV2 does.
  • Document the package as basic-accounts-only for now.

No test uses a distinct provider yet.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@pepebndc We can discuss the authorization model now that we have an implementation, if we want to be "owner or provider", or "owner and provider". I would go for "owner or provider" cc. @ericnordelo

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed to decide this in the sync. "Owner or provider" also satisfies the CIP-112 MUST, because the owner can then call both factories alone.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@igingu why not making that configurable using an AccountConfig?

Comment thread packages/token/tokenCIP112-v1/daml/OpenZeppelin/TokenCIP112V1/Holding.daml Outdated
Comment thread packages/token/tokenCIP112-v1/README.md Outdated
Comment on lines +68 to +71
deployment target requires V1 visibility, add the V1 interface instances to
the same templates: the V1 DARs are vendored under
[`dars/vendor/`](../../../dars/vendor/), and the vendored utils ship
`...V1...DefaultImplUsingV2` helpers for exactly this pattern.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

V1 on TokenAllocation conflicts with CIP-112 5.1

Assets MUST NOT implement the V1 Allocation interface on Allocations that are not settleable via the V1 Allocation_ExecuteTransfer flow. In practice this means assets need to maintain two Allocation implementations, one for V2-only flows, one for V1/V2 dual-compatibility flows.

TokenAllocation supports multi-leg, committed and iterated allocations, so it cannot carry V1. The DefaultImplUsingV2 helpers still fit TokenHolding, TokenTransferInstruction and the factories, where V1 is a SHOULD. V1 allocations need a separate, constrained template, as TestTokenV2 does. The same applies to ideas.md item 1.

Because current wallets and exchanges use V1, this is also the largest adoption gap.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OK to discuss in the sync. If the package stays V2-only, update the README "Standards conformance" paragraph and ideas.md item 1 in a follow-up, because they still suggest adding V1 to TokenAllocation.

@pepebndc pepebndc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@pepebndc pepebndc linked an issue Sep 24, 2026 that may be closed by this pull request
Comment thread CHANGELOG.md Outdated
`OpenZeppelin.TokenCIP112V1`. It implements the Token Standard V2 interfaces
and builds against the 13 vendored Token Standard V2 DARs under
`dars/vendor/`, with provenance recorded in `dars/manifest.yaml`.
and builds against the 13 official Token Standard V2 DARs released by Splice

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we simplify the token entries here in general, it reads as if have been being incrementaly modified, but the added section should contain just the aggregation of what is being added as a unit, as this is all part of the same unreleased version, not a diff. I would also try to keep the Changelog entries as concise and short as possible.

-- pending instruction state exists in this registry because allocation needs
-- no approval beyond the authorizer's account parties.
allocateImpl
: TokenRules

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we make this function depend on an allocation config instead? The advantages are:

  • Allowing to reuse this allocation logic in custom templates without needing to create a TokenRules record without the contract
  • Allowing the extend the TokenRules state later without affecting the allocation logic:
data AllocationConfig = AllocationConfig with
  admin : Party
  maxTTL : RelTime
  lockGrace : RelTime

@ericnordelo ericnordelo Sep 29, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Someone could do something like this (note how they can use our helpers partially)

Code Example

module MyApp.AllocationFactory where

import DA.Time (RelTime, seconds)

import Splice.Api.Token.MetadataV1
import Splice.Api.Token.AllocationInstructionV2 qualified as AI
import Splice.TokenStandard.Utils

import OpenZeppelin.TokenCIP112V1.Registry qualified as OZ

template ExchangeAllocationFactory
  with
    admin : Party
    allowedExecutors : [Party]
    maxTTL : RelTime
    lockGrace : RelTime
  where
    signatory admin

    ensure maxTTL > seconds 0
      && lockGrace > seconds 0

    interface instance AI.AllocationFactory
        for ExchangeAllocationFactory where

      view = AI.AllocationFactoryView with
        admin
        meta = emptyMetadata

      allocationFactory_publicFetchImpl =
        publicFetchDefaultImpl this

      allocationFactory_allocateExtraObservers =
        allocationFactoryV2_privateAsset_allocateExtraObserversDefaultImpl

      allocationFactory_allocateImpl _ arg = do
        -- My application's additional rule.
        assertMsg "An executor is not approved"
          (all
            (\executor -> executor `elem` allowedExecutors)
            arg.settlement.executors)

        -- Construct the library template's payload just to call its helper.
        let rules = OZ.TokenRules with
              admin
              maxTTL
              lockGrace

        -- Reuse the library's allocation mechanics.
        OZ.allocateImpl rules arg

@ericnordelo

Copy link
Copy Markdown
Member

Hey, I’ve been looking at how integrators could use this package as a data dependency while owning the contracts they deploy.

TLDR: What I'm proposing is simpler than it looks here to implement, but sort of hard to explain. The idea is to use interface exercising to separate templates from workflows, and to isolate templates on their own packages so users can choose to use them as a direct dependency or a fork, with the option of using the rest of the library always as a direct dependency, with the option of opting out at any point if templates are decoupled.

More details

I think we should make the concrete templates replaceable, while keeping our current templates as convenient defaults. The main benefit is letting consumers customize their contracts and manage their own upgrade lineage while continuing to reuse our workflow logic.

1. Separate workflow logic from contract creation

I’d make helpers receive creation functions instead of directly creating TokenHolding, TokenTransferInstruction, or TokenAllocation. For example, the holding constructor could have this signature:

type CreateHolding =
  H.HoldingView -> Update (ContractId H.Holding)
A consumer could provide:

createMyHolding auditor holding = do
  cid <- create MyHolding with holding; auditor
  pure (toInterfaceContractId @H.Holding cid)

Our default implementation would supply the equivalent constructor for TokenHolding. This works across compiled DAR data dependencies, so consumers can import our helpers without copying their implementation. A dedicated on-ledger holding factory isn’t necessary for this approach.

2. Use existing Splice interfaces at contract boundaries

Where the standard already provides the required operation, helpers should work with interface contract IDs and interface fetches/exercises. That lets the same workflow code interact with different consumer implementations. It also lets unchanged callers use compatible newer template implementations through dynamic package selection.
I wouldn’t add another holding interface just to duplicate the Splice view. Additional interfaces should expose capabilities that our workflows actually need.

We still need an explicit policy for accepted implementations: implementing a standard interface alone does not establish that a contract is trusted. Existing concrete-template guards need a deliberate replacement, rather than simply being removed.

3. Carry the abstraction through pending workflows

Custom holding creation needs to work when a workflow finishes later, including acceptance, rejection, withdrawal, settlement, and release. Functions cannot be stored in contract payloads. A consumer-owned transfer instruction can store ordinary configuration and supply its constructor again when executing a later choice:

-- Illustrative delegation inside the consumer's instruction:
finishTransfer
  (createMyHolding auditor)
  self
  transfer
  lockedHolding

This is why I think replaceable transfer instructions matter even when the consumer only wants a custom holding: the instruction determines which holding implementation gets created when the transfer completes or refunds. The same reasoning applies to allocations, including any successor allocation created during iteration. Our helpers should retain the shared validation and accounting; the consumer wrapper supplies its configuration and implementation choices.

4. Separate template ownership from spending behavior

Creation callbacks allow custom fields, additional choices, and consumer-owned template evolution. They do not automatically support arbitrary asset behavior. For example, fetching and archiving through the holding interface does not execute a custom Spend or Freeze choice. If we intend to support custom spending semantics, we should expose an explicit consumption operation, through a callback or a dedicated interface choice. Otherwise, we should clearly document the holding behavior our helpers assume. ’d keep v1’s supported behavior explicit and introduce additional operations when there is a concrete integration requiring them.

5. Structure packages around the intended upgrade independence

I’d separate:

  • Reusable workflow helpers.
  • Stable shared interfaces and data definitions.
  • Default concrete templates.

Consumer-owned templates give integrators their own upgrade lineage. Interface dispatch allows callers to follow compatible implementations, but normal SCU restrictions still apply. There is also a packaging distinction: a function-only utility dependency can be replaceable in ways that a dependency defining templates, interfaces, or serializable types is not. Separate modules within one package do not provide that separation. So we should promise independent evolution of consumer implementations, while being precise about the shared API dependencies they must retain. Existing contracts created from our templates would still require a migration path to move into unrelated consumer templates.

6. What I verified in a PoC

Using SDK 3.4.11, LF 2.1, and the actual Splice V2 interfaces, I verified:

  • Constructor callbacks work through a compiled helper DAR.
  • A later acceptance transaction creates the consumer’s custom holding.
  • An unchanged client selects an upgraded implementation through interface fetch/exercise.
  • A pending v1 instruction completes using its compatible v2 implementation.
  • A compatible consumer upgrade can replace a function-only helper dependency.

The runtime scripts and standalone upgrade checks passed. Storing a callback in a contract and making an incompatible field change were correctly rejected. This verifies the composition mechanism in a simulated ledger; it is not a full CIP-112 conformance or multi-participant deployment test.

For v1, I’d prioritize constructor callbacks, existing Splice interface boundaries, and thin consumer-owned workflow wrappers, alongside the ready-to-use defaults. That gives us a concrete extension path without requiring a general-purpose factory framework.

@igingu

igingu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

@ericnordelo I have updated the PR to split the existing functionality into two packages: tokenCIP112-workflows-v1 contains common logic for accounting, netting, settling, and refers solely to the Splice V2 interfaces, it has no knowledge of templates; tokenCIP112-v1 which is a default template implementation, leveraging the workflows.

In order for templates to be able to customise part of the shape of a holding, as well as key implementation steps, I added a HoldingOps type - a consumer is supposed to define custom logic for creating a holding, consuming a holding, adding custom logic when emitting an EventLog, as well as adding extra observers to the EventLog_HoldingsChange event. The HoldingOps will be passed as a parameter into the workflows, and used as hooks basically.

We had a discussion about using this method of passing hooks versus using and exercising on interfaces. Based on my limited research, there aren't many significant benefits to switching to interfaces, so I kept the same shape of the code.
Based on your comments, what is left to do is add a customisable authorization model, similar to AccountConfig but simpler - i.e. the deployer could choose the authorization model that applies to all accounts.

cc. @pepebndc @0xNeshi

Comment thread ARCHITECTURE.md
their own ships two production packages:

```text
<component>-workflows-v1 Choice bodies as functions, parameterised over the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
<component>-workflows-v1 Choice bodies as functions, parameterised over the
<component>-utils-v1 Choice bodies as functions, parameterised over the

All other relevant refs and names must be updated, as per #55

Comment on lines +66 to +74
nonconsuming choice TokenRules_Mint : ContractId HoldingV2.Holding
-- ^ Issue holdings into an account, emitting the standard mint event.
with
account : HoldingV2.Account
instrumentId : Text
amount : Decimal
reason : Text
controller config.admin :: accountParties config.admin account
do mintImpl (rulesOps config) config.admin account instrumentId amount reason

@0xNeshi 0xNeshi Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minting doesn't work if admin and account are on different participants; even on same participants, account must give the participant node submission rights.

The current minting flow requires the admin to always mint first to itself, and then initiate a regular two-step transfer.

The same goes for TokenRules_Burn flow, only in reverse (first transfer to admin, then admin invokes burn)

Was this design intentional?

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] CIP112 implementation revamp

4 participants