Skip to content

feat: add CIP-113 programmable token support#68

Open
matiwinnetou wants to merge 72 commits intodevelopfrom
CIP-113-token
Open

feat: add CIP-113 programmable token support#68
matiwinnetou wants to merge 72 commits intodevelopfrom
CIP-113-token

Conversation

@matiwinnetou
Copy link
Copy Markdown
Contributor

@matiwinnetou matiwinnetou commented Mar 25, 2026

Summary

Fixes #67

Adds CIP-113 programmable token support to the token metadata registry. CIP-113 defines an on-chain registry where tokens are registered with transfer validation logic — enabling regulatory compliance, freeze/seize capabilities, and custom transfer rules for Cardano native assets.

V2 Subject Response Changes

Token type field (additive)

A new type field classifies tokens as NATIVE (standard) or PROGRAMMABLE (has on-chain transfer logic):

{
  "subject": {
    "subject": "577f0b...0014df10464c4454",
    "type": "PROGRAMMABLE",
    "metadata": {
      "name": { "value": "FLDT", "source": "CIP_68" },
      "description": { "value": "A regulated stablecoin", "source": "CIP_68" }
    },
    "extensions": {
      "cip113": {
        "transfer_logic_script": "aaa513b0fcc01d635f8535d49f38acc33d4d6b62ee8732ca6e126102",
        "third_party_transfer_logic_script": "def513b0fcc01d635f8535d49f38acc33d4d6b62ee8732ca6e126103",
        "global_state_policy_id": null
      }
    }
  },
  "queryPriority": ["CIP_68", "CIP_26"]
}

For non-programmable tokens, type is NATIVE and extensions is omitted — fully backward compatible.

Implementation

On-chain indexing

  • Cip113EventListener — filters for registry node NFTs, parses inline CBOR datums
  • Cip113RegistryNodeParser — parses CIP-113 RegistryNode PlutusData; third_party_transfer_logic_script and global_state_policy_id are nullable (per CIP-113 spec, not all substandards require them)
  • Cip113RegistryNode entity — JPA entity with composite PK, append-only log
  • Cip113RegistryService — single and batch lookups for CIP-113 data
  • CustomUtxoStorage — persists CIP-113 registry node UTxOs alongside CIP-68

Extensions model (ADR-015)

  • Generic extensions: Map<String, Extension> field on V2 Subject response
  • Extension marker interface — ProgrammableTokenCip113 is the first implementation
  • TokenType enum (NATIVE/PROGRAMMABLE) derived from extensions presence
  • Omitted when empty — backward compatible

CIP-113 fields

Field Required Nullable Description
transfer_logic_script no yes Blake2b-224 hash of the Plutus script validating every transfer
third_party_transfer_logic_script no yes Script hash for issuer/admin operations (freeze, seize, burn)
global_state_policy_id no yes Policy ID of optional global state NFT (e.g. denylist)

Configuration

CIP-113 is implicitly enabled when CIP113_REGISTRY_NFT_POLICY_IDS contains at least one policy ID.

Preview network

.env.preview added for testing against Cardano preview testnet where CIP-113 tokens are deployed. Registry Mint policy ID: 49fb5fddb1b04ca8d2e2edd6f17a4ef00c726d69e4cd98c4d0c89d85.

ADRs

  • ADR-015 — V2 API Extensions Model (token type classification, query priority)
  • ADR-016 — CIP-113 Programmable Token Support

Tests

Unit tests (139 total):

  • Cip113RegistryServiceTest — 10 tests (find, batch, disabled, containsRegistryNode)
  • Cip113RegistryNodeParserTest — 7 tests (full datum, sentinel, no global state, null third party, invalid inputs)
  • Cip113EventListenerTest — 5 tests (save, skip disabled/non-matching/no-datum/invalid)
  • MetadataApiV2ControllerTest — 14 tests (type=NATIVE/PROGRAMMABLE, extensions, batch, priority)

Integration tests:

  • Cip113IntegrationIT — end-to-end on yaci devnet: mints registry nodes, verifies metrics, tests combined CIP-68+CIP-113 subject response with type=PROGRAMMABLE
  • OpenApiDocsIT — verifies type field and extensions in OpenAPI schema

Test plan

  • mvn clean verify — all 139 unit tests pass
  • Integration tests on yaci devnet (CI green)
  • Backward compatibility — existing V2 subject endpoints unchanged, extensions omitted when empty
  • Preview network .env.preview for CIP-113 token testing

Notes

CIP-113 is under active development (CIP PR #444) and has not been finalized. This implementation may need to evolve as the specification matures.

@matiwinnetou matiwinnetou added this to the 1.6.0 milestone Mar 25, 2026
Mateusz Czeladka and others added 9 commits March 26, 2026 15:27
Add on-chain indexing and V2 API enrichment for CIP-113 programmable
tokens. CIP-113 tokens are standard CIP-26/CIP-68 tokens registered
in an on-chain programmable registry with transfer validation logic —
effectively tokens in a "smart contract jail" for regulatory compliance,
freeze/seize, or custom transfer rules.

## On-chain indexing
- Cip113EventListener listens to Yaci Store AddressUtxoEvent, filters
  for registry node NFTs matching configured policy IDs, parses inline
  datums via Cip113RegistryNodeParser
- Append-only cip113_registry_node table with composite PK
  (policy_id, slot, tx_hash) and DISTINCT ON query for latest state
- Supports multiple protocol versions via comma-separated policy IDs

## V2 API — generic extensions map
- New Extension marker interface with Map<String, Extension> on Subject
- CIP-113 data exposed under extensions.cip113 with transfer_logic_script,
  third_party_transfer_logic_script, and global_state_policy_id
- Extensible — future CIPs add their own key without changing Subject
- Backward compatible — extensions omitted for non-programmable tokens
- OpenAPI @Schema annotations with descriptions, examples, and
  required/nullable for all fields

## Configuration
- cip113.enabled (env: CIP113_ENABLED, default: false)
- cip113.registry.nft.policy-ids (env: CIP113_REGISTRY_NFT_POLICY_IDS)
- Disabled by default, no impact on existing CIP-26/CIP-68 flows

## Tests
- Unit: parser (6), service (5), V2 controller (3 new)
- Integration: registry node mint + indexing on yaci devnet, combined
  CIP-68 + CIP-113 V2 response verification, Prometheus metric polling
- CI enabled with CIP113_ENABLED=true for integration tests

## Version bump: 1.5.0 → 1.6.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verify ProgrammableTokenCip113 schema with its three fields,
Subject extensions map, and Extension base schema are documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace var usage in OpenApiDocsIT with explicit Iterator/Map.Entry
types. Add explicit-types-only rule to CLAUDE.md coding conventions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Refactor Cip113EventListenerTest, Cip113RegistryNodeParserTest, and
Cip113RegistryServiceTest to use @nested grouping and AssertJ assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Springdoc cannot generate ProgrammableTokenCip113 as a standalone
schema without @JsonSubTypes on Extension. Replace field-scanning
assertion with a check that Subject.extensions is a map type with
additionalProperties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add @Schema(oneOf) to Extension interface so springdoc generates the
ProgrammableTokenCip113 schema with its three fields. This makes the
CIP-113 extension visible in Scalar and other OpenAPI frontends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Annotate V2ApiController.buildExtensions with jakarta.annotation.Nullable
since it returns null when no extensions are found. Add nullable-return
convention to CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix testing strategy section to distinguish controller tests (@WebMvcTest)
from end-to-end integration tests (RestTemplate in integration-test/).
Add integration-test module to architecture overview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…etails

Rewrite CLAUDE.md to reflect the full multi-standard registry:
- CIP-26 offchain, CIP-68 on-chain, CIP-113 programmable tokens
- V2 query priority mechanism and metadata merging
- Yaci Store integration and on-chain indexing
- All operational endpoints (startup/liveness/readiness probes,
  prometheus, metrics, admin-ui) with health indicator groups
- Mark /health as deprecated
- Configuration table with key environment variables
- Coding conventions: no var, @nullable, records over tuples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mateusz Czeladka and others added 11 commits March 26, 2026 15:58
FLDT is not a CIP-113 programmable token on mainnet. Update comments
to make clear the CIP-113 extension is mocked for testing purposes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove @nullable from transferLogicScript and thirdPartyTransferLogicScript
in ProgrammableTokenCip113 since they are required per CIP-113 spec.
Add validation in Cip113RegistryNodeParser to return Optional.empty()
when either required script cannot be extracted from the datum.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ct pattern)

Collections should never be null — return empty containers and let
@JsonInclude(NON_EMPTY) handle serialization omission. Also harden
Prometheus metric parsing in CIP-113 integration test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-113 IT

Use DocumentContext.read("$.path") instead of manual JsonNode.get()
chains for cleaner, more readable assertions. No new dependencies —
com.jayway.jsonpath is already provided by spring-boot-starter-test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… IDs

CIP-113 is now enabled when CIP113_REGISTRY_NFT_POLICY_IDS is non-empty,
eliminating the redundant boolean flag. The enabled field was always
checked alongside the policy ID set, making it unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e tokens)

ADR-015 documents the V2 API extensions model — why orthogonal CIP data
needs a separate response section from the metadata merge model, and how
the Extension interface + map-based field enables future CIPs.

ADR-016 documents CIP-113 specifically — on-chain indexing, CBOR parsing,
implicit enablement, and batch optimization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skipsWhenDisabled and skipsWhenNoPolicyIdsConfigured became identical
after removing the CIP113_ENABLED flag. Kept the descriptive name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…onIT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve merge conflict in OpenApiDocsIT by converting CIP-113 test
section from Jackson to JsonPath, consistent with the develop refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Path reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mateusz Czeladka and others added 3 commits April 1, 2026 15:17
Move metadata_reference_nft indices out of V3 (CIP-113) into
V4__performance_improvements.sql since they are not CIP-113 specific.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bare `from conftest import` fails when pytest is invoked from the repo
root. Relative imports work regardless of working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@nemo83 nemo83 left a comment

Choose a reason for hiding this comment

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

LGTM, just a question about a db field

Mateusz Czeladka and others added 18 commits April 2, 2026 14:42
- Add BatchPrefetchData record to wrap 3 bulk-fetched maps (CIP-26,
  CIP-68, CIP-113) issued upfront in prefetchBatch(), replacing
  per-subject DB queries with O(1) map lookups

- Add findLatestByPolicyIds() to MetadataReferenceNftRepository using
  ROW_NUMBER() window function for batch CIP-68 reference NFT retrieval

- Replace PostgreSQL-specific DISTINCT ON in Cip113RegistryNodeRepository
  with ROW_NUMBER() window function for portability (PostgreSQL, H2, MySQL)

- Add batch-aware findSubject() overload to Cip68FungibleTokenService
  that reads from pre-fetched map instead of hitting DB per subject

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…migration

Add SQL comment explaining policy_id is the 'key' field from the registry
node datum. Remove idx_cip113_registry_node_policy_slot index since the PK
(policy_id, slot, tx_hash) supports backward scans for the same query pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolved conflict in pom.xml by dropping the orphaned <revision>1.6.0</revision>
property, since origin/main removed the ${revision} mechanism and hard-coded
1.4.4 across all poms to fix release-please.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reorder cip68_tokens.json so entries with larger (newer) slot values
appear first, tiebroken by subject for stable ordering. Update
generate_fixtures.py to match so future regenerations preserve the order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Mateusz Czeladka and others added 4 commits April 11, 2026 09:25
Align SQL column types with the explicit spec limits and add matching
@column(length=...) annotations to the JPA entities (previously none).
Bounds now bit-compatible with yaci-store/extensions/assets-ext, which
this module is intended to eventually replace.

CIP-26 metadata / logo — all bounds match the cf-tokens-cip26 validator
constants enforced in MetadataValidationRules:
- subject: varchar(255) → varchar(120)
- policy: text → varchar(120)
- name: varchar(255) → varchar(50)
- ticker: varchar(32) → varchar(9)
- url: varchar(255) → varchar(250)
- description: text → varchar(500)

CIP-68 metadata_reference_nft — on-chain columns bounded by Cardano
ledger rules; datum-sourced strings bounded defensively:
- policy_id: text → varchar(56)
- asset_name: text → varchar(64)
- name: text → varchar(255)
- ticker: text → varchar(32)
- url: text → varchar(250)
- description, logo, datum: kept as text

CIP-113 cip113_registry_node — tightened based on Aiken linked-list
conventions confirmed via the cip113-programmable-tokens spec:
- policy_id, next_key: text → varchar(64)
  (head sentinel '', real 28-byte policy = 56 hex, tail sentinel ≤ 32
  bytes = 64 hex)
- tx_hash: text → varchar(64)
- transfer_logic_script, third_party_transfer_logic_script,
  global_state_policy_id: text → varchar(56)

All inline comments explain the spec source of each bound. No new
migration file — V0/V1/V3 are edited in place (consistent with yaci-store;
re-ingest from genesis per current rollout plan). Indexes, the textsearch
tsvector column, FK logo→metadata, and wallet_scam_lookup are untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commit tightened the CIP-113 schema columns from text to
varchar(64)/varchar(56). Without matching parser-level validation, a
malicious inline datum with long byte strings would pass the parser and
fail the DB insert, rolling back the Cip113EventListener transaction
and wedging sync on retry. This commit closes the gap by porting the
hardened parser from yaci-store/extensions/assets-ext.

Invariants now enforced (identical to yaci-store):

1. Root must be ConstrPlutusData with alternative 0.
2. Exactly 5 fields.
3. key: ByteString, 0..32 bytes (head sentinel, real 28-byte policy,
   or 32-byte tail sentinel).
4. next: ByteString, 1..32 bytes (non-empty).
5. transfer_logic_script / third_party_transfer_logic_script: Aiken
   Credential Constr (alt 0 VerificationKey or 1 Script) wrapping
   exactly 28 bytes. Absent-credential tolerance preserved —
   BytesPlutusData(h'') or Credential wrapping empty bytes → null.
6. global_state_cs: ByteString of exactly 0 or 28 bytes.

Catch clause narrowed from catch(Exception) to
catch(CborDeserializationException | RuntimeException) so Error
instances propagate past the parser (caller is responsible for the
batch boundary).

Other changes:

- ParsedRegistryNode gains isHeadSentinel().
- Cip113RegistryNodeParserTest rewritten: 36 test cases covering every
  invariant above, plus positive cases for head sentinel, tail sentinel,
  all-three-optional-absent, and both Credential variants.
- Cip113EventListenerTest: TRANSFER_LOGIC and THIRD_PARTY_LOGIC
  constants were previously 58 hex chars (29 bytes) — latent bug masked
  by the old lenient parser. Fixed to 56 hex (28 bytes).
  savesEntityWithNullTransferLogicScript and
  buildRegistryNodeDatumNullThirdParty helper extended from 4 fields
  to 5 (the new parser requires all 5).

Kept in lockstep with yaci-store/extensions/assets-ext
Cip113RegistryNodeParser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a hard 4096-hex-char size cap at Cip113RegistryNodeParser.parse
entry, before HexUtil and PlutusData.deserialize are ever called. This
is the only parser-layer defence against the library-layer DoS classes:

- deeply nested Constr → StackOverflowError
- CBOR pre-allocation bomb → OutOfMemoryError

Both manifest as Error (not RuntimeException) inside the recursive CBOR
decoder and would propagate past the parser's catch clause, killing the
event-listener thread. A real CIP-113 RegistryNode encodes to roughly
200-400 bytes (≤ 800 hex chars), so 4096 gives ~10× safety margin.
4096 bytes into the decoder physically can't contain enough nesting to
blow a reasonable JVM stack or trigger the pre-allocation bomb.

Also adds rejectsHexExceedingMaxSize test proving the size cap fires
before HexUtil (uses valid hex characters to rule out hex-decode
rejection). Brings MalformedInputs nested class to 5 tests, total
parser test count to 37.

Kept in lockstep with yaci-store/extensions/assets-ext.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Javadoc enforces heading sequence starting at <h2> (since the implicit
<h1> is the class name). Changed the three section headers (Absent
credential encoding / Byzantine safety / Non-enforced invariant) to
<h2> to keep in lockstep with yaci-store's Cip113RegistryNodeParser
and to avoid the same javadoc task failure if strict doclint is
enabled here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants