Programmable Tokens with CIP-113
A native Cardano token moves freely once it’s minted. Nobody can block a transfer, freeze a holder or recover stolen funds, which rules out many regulated assets. CIP-113 adds rules without a hard fork: tokens sit at a shared script, and they change hands only if the token’s own logic script approves.
In this tutorial you’ll create a token that can’t move without its issuer’s signature, then take it through its whole life on the Preview testnet:
- Register the token and mint 1,000 to Alice.
- Watch a transfer fail because the issuer didn’t approve it, then send an approved one to Bob.
- Seize tokens from Bob without his signature.
- Burn what you seized.
You finish with Alice holding 900 tokens, Bob holding 60, and a total supply of 960.
You need: Java 17+, and some experience with QuickTx. If you haven’t built a transaction with this library yet, start with your first transaction.
How CIP-113 works
Section titled “How CIP-113 works”Four ideas explain most of what follows.
Smart wallet. Programmable tokens never sit at an ordinary address. Each holder has a smart wallet: an address whose payment part is the shared programmable_logic_base script and whose stake part identifies the owner. Anyone can derive it from the owner’s normal address, so a receiver doesn’t have to set anything up. Once minted, tokens move only between smart wallets.
Registry. A shared on-chain linked list with one node per token. Each node records the scripts that govern that token, and anyone can add a node.
Module. Those scripts are the token’s rules, and you write them. Together they form the token’s module, which the CIP calls a substandard. The two terms mean the same thing.
| Script | Field in CCL | Runs when |
|---|---|---|
| Minting logic | mintingLogicScript | Tokens are minted or burned |
| Transfer logic | transferLogicScript | A holder moves or burns their own tokens |
| Third-party logic | thirdPartyTransferLogicScript | Someone acts on another holder’s tokens, such as a seizure |
The CIP calls the minting logic issuanceLogicScript. A fourth script, unfrackingLogicScript, defaults to forbidden; see Limits.
Withdraw-zero. Logic scripts run as a withdrawal of 0 lovelace from the script’s own reward account. A script can then take part in a transaction without holding anything, but its reward account has to be registered first.
shared protocol, already deployed +-----------------------------------------------------------+ | registry --> your token's node --> your logic scripts | +-----------------------------------------------------------+ | checked on every move | Alice's smart wallet ------+------> Bob's smart wallet (base script + Alice's key) (base script + Bob's key)You don’t deploy any of this. The library ships descriptions of the public deployments as Cip113Deployments.PREVIEW, PREPROD and MAINNET. This tutorial uses Preview because that deployment already exists. A new Yaci DevKit devnet doesn’t have one. You can deploy the protocol there with ordinary QuickTx transactions, but the library has no shortcut for it. If you really want to do that, see how the library’s end-to-end tests deploy it, and pass the resulting description to Cip113ProgrammableTokenService.create.
When you build a transaction, the library finds the registry node, adds the protocol’s reference inputs, writes its redeemers and sizes the min-ADA on each output. You only state what you want to happen.
Set up your project
Section titled “Set up your project”Add these dependencies, replacing <version> with the release you’re using. cardano-client-programmable-token isn’t part of the cardano-client-lib aggregate, so it has to be listed on its own.
def cclVersion = '<version>'
dependencies { implementation "com.bloxbean.cardano:cardano-client-lib:$cclVersion" implementation "com.bloxbean.cardano:cardano-client-backend-blockfrost:$cclVersion" implementation "com.bloxbean.cardano:cardano-client-programmable-token:$cclVersion" implementation 'com.bloxbean.cardano:aiken-java-binding:0.1.0'}aiken-java-binding applies your script’s parameter and evaluates scripts locally. It bundles native libraries for macOS, Windows and x86-64 Linux, but not ARM64 Linux. On ARM64 Linux, use Scalus instead.
You also need a free Blockfrost project for Preview and a funded wallet. Create a wallet with this class:
import com.bloxbean.cardano.client.account.Account;import com.bloxbean.cardano.client.common.model.Networks;
public class CreateWallet {
public static void main(String[] args) { Account account = new Account(Networks.preview()); System.out.println("Mnemonic: " + account.mnemonic()); System.out.println("Fund this address: " + account.baseAddress()); }}Send at least 50 tADA to the printed address from the testnet faucet, selecting Preview. Then export your settings:
export BF_PROJECT_ID=<your Blockfrost Preview project id>export TUTORIAL_MNEMONIC="<the words printed above>"Step 1: Connect to the deployment
Section titled “Step 1: Connect to the deployment”The code below derives three accounts from your mnemonic. The issuer is account 0, the address you funded. Alice and Bob are accounts 1 and 2.
public class IssuerApprovedToken {
/** issuer_approved.issuer_approved.withdraw from plutus.json, before the issuer is applied. */ private static final String COMPILED_CODE = "5895010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264646644b3001" + "3370e900218039baa00189991198008009bac300c300d300d300d300d300d300d300d300d30" + "0a3754601800c601460106ea8004896600200314a115980099b8f375c601800200f14a31330" + "02002300d0014020805a2c8030c020004c020c024004c020004c010dd5004452689b2b200401";
private static final String ASSET_NAME = "TutorialToken";
/** How long to wait for a submitted transaction to land in a block. */ private static final Duration CONFIRM_TIMEOUT = Duration.ofMinutes(5);
/** How long to let the backend's UTxO view catch up after a confirmed transaction. */ private static final Duration SETTLE_TIME = Duration.ofSeconds(60);
private final BackendService backend; private final Network network; private final Account issuer; private final Account alice; private final Account bob;
private final Cip113ProgrammableTokenService tokens; private final Cip113ProtocolService protocol; private final Address issuerAddress;
private PlutusScript logicScript; private Credential logic; private String policyId;
public IssuerApprovedToken(BackendService backend, Cip113ProgrammableTokenService tokens, Network network, Account issuer, Account alice, Account bob) { this.backend = backend; this.network = network; this.issuer = issuer; this.alice = alice; this.bob = bob; this.issuerAddress = new Address(issuer.baseAddress()); this.tokens = tokens; this.protocol = tokens.advanced(); }
public static void main(String[] args) throws Exception { String projectId = System.getenv("BF_PROJECT_ID"); String mnemonic = System.getenv("TUTORIAL_MNEMONIC"); Network network = Networks.preview();
BackendService backend = new BFBackendService(Constants.BLOCKFROST_PREVIEW_URL, projectId);
// One mnemonic, three separate accounts: the issuer and two holders. Account issuer = Account.createFromMnemonic(network, mnemonic, 0, 0); Account alice = Account.createFromMnemonic(network, mnemonic, 1, 0); Account bob = Account.createFromMnemonic(network, mnemonic, 2, 0);
Cip113ProgrammableTokenService tokens = Cip113ProgrammableTokenService.create(backend, Cip113Deployments.PREVIEW);
new IssuerApprovedToken(backend, tokens, network, issuer, alice, bob).run(); }
public void run() throws Exception { prepareLogicScript(); // Needed on every run. It submits nothing.
// Run 1: fund Alice, register the token and mint. // These work once per wallet, so comment them out before run 2. fundAlice(); registerRewardAccount(); registerAndMint(); printBalances("after mint");
// Run 2: uncomment to try a transfer. // transferWithoutApproval(); // transfer(); // printBalances("after transfer");
// Run 3: uncomment to seize and burn, after commenting out run 2. // seize(); // printBalances("after seizure"); // burn(); // printBalances("final"); }
// The methods below go here.}Cip113ProgrammableTokenService is the entry point. tokens.extension() plugs the protocol into QuickTx, and tokens.advanced() gives you the read side used throughout this tutorial.
The sections below add one method each to this class, and the complete class is at the end.
Step 2: Write the token’s rule
Section titled “Step 2: Write the token’s rule”This token has a single rule, written in Aiken: the issuer must have signed.
use aiken/collection/listuse aiken/crypto.{VerificationKeyHash}use cardano/address.{Credential}use cardano/transaction.{Transaction}
/// Passes only when `issuer` signed the transaction.validator issuer_approved(issuer: VerificationKeyHash) { withdraw(_redeemer: Data, _account: Credential, tx: Transaction) { list.has(tx.extra_signatories, issuer) }
else(_) { fail }}You don’t need Aiken installed. The compiled validator is the COMPILED_CODE constant in the class above, built with Aiken v1.1.23 and stdlib v3.0.0.
One script serves all three roles here, so there’s only one reward account to register. A real token would usually have a separate script for each role.
issuer is a parameter, so every issuer gets a different script hash. That matters because a token’s policy id is derived from its minting script’s hash. You don’t choose the policy id, and two tokens with the same minting script would be the same token. Your parameterized script gives you a policy nobody else in the shared registry has.
private void prepareLogicScript() throws Exception { byte[] issuerKeyHash = issuer.hdKeyPair().getPublicKey().getKeyHash();
String applied = AikenScriptUtil.applyParamToScript( ListPlutusData.of(BytesPlutusData.of(issuerKeyHash)), COMPILED_CODE); logicScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(applied, PlutusVersion.v3); logic = Credential.fromScript(logicScript.getPolicyId());
// Nothing on chain has used this script yet, so no backend can serve it. protocol.scripts().register(logicScript);
policyId = value(protocol.derivePolicyId(logic)); System.out.println("Logic script : " + logicScript.getPolicyId()); System.out.println("Policy id : " + policyId);}The register call is needed because a backend can serve a script only after the chain has revealed it, and yours hasn’t been used yet.
Step 3: Fund Alice and register the reward account
Section titled “Step 3: Fund Alice and register the reward account”Alice needs ADA at her normal address for fees and collateral:
private void fundAlice() { Tx tx = new Tx() .payToAddress(alice.baseAddress(), Amount.ada(30)) .from(issuer.baseAddress());
check("Fund alice", new QuickTxBuilder(backend) .compose(tx) .withSigner(SignerProviders.signerFrom(issuer)) .completeAndWait(CONFIRM_TIMEOUT));}Every submission in this tutorial goes through check, which stops unless the transaction is confirmed and then pauses:
private static void check(String step, TxResult result) { if (!result.isSuccessful()) throw new IllegalStateException(step + " failed: " + result.getResponse()); if (result.getTxStatus() != TxStatus.CONFIRMED) throw new IllegalStateException(step + " not confirmed (" + result.getTxStatus() + "): " + result.getValue()); System.out.printf("%-12s : %s%n", step, result.getValue()); settle();}
private static void settle() { try { Thread.sleep(SETTLE_TIME.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }}Both checks matter on a public network:
- Confirmation.
completeAndWait()waits 60 seconds by default. If the transaction hasn’t landed by then, the result still reportsisSuccessful(), with the statusTIMEOUT. Blocks can be slow on Preview, where one of our test transactions took about two minutes, so this code waits up to five minutes and accepts onlyCONFIRMED. - The pause. Blockfrost’s UTxO queries can lag behind a confirmed transaction: we measured anywhere from 1 to 37 seconds on Preview. Without the pause, the next transaction can pick a UTxO that was just spent.
This keeps the tutorial simple, but a production application should chain transactions properly, for example with TxFlow.
Next, register the script’s reward account so that withdraw-zero can run it. This needs no script witness. The check for StakeKeyRegistered lets you run this step again safely.
private void registerRewardAccount() { String rewardAddress = AddressProvider.getRewardAddress(logic, network).toBech32();
TxResult result = new QuickTxBuilder(backend) .compose(new Tx() .registerStakeAddress(rewardAddress) .from(issuer.baseAddress())) .withSigner(SignerProviders.signerFrom(issuer)) .completeAndWait(CONFIRM_TIMEOUT);
if (!result.isSuccessful() && result.getResponse().contains("StakeKeyRegistered")) { System.out.println("Reward account already registered"); return; } check("Reward acct", result);}Step 4: Register the token and mint
Section titled “Step 4: Register the token and mint”Every programmable-token transaction goes through this helper:
private QuickTxBuilder.TxContext builder(Tx tx, Account sender) { return new QuickTxBuilder(backend) .withExtension(tokens.extension()) .compose(tx) .withSigner(SignerProviders.signerFrom(sender)) .withTxEvaluator(new AikenTransactionEvaluator(backend));}withExtension is what turns verbs like register and mint into a real transaction. withTxEvaluator runs the scripts locally, so a script failure stops the build before anything is submitted.
Now register the token and mint its first supply in one transaction:
private void registerAndMint() { RegistryNodeSpec spec = RegistryNodeSpec.builder() .mintingLogicScript(logic) .transferLogicScript(logic) .thirdPartyTransferLogicScript(logic) .build();
ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(issuer.baseAddress()) .register("tutorial", Cip113Registration.from(spec), PlutusData.unit()) .mint(ProgrammableTokenPolicyRef.named("tutorial"), alice.baseAddress(), List.of(new Asset(ASSET_NAME, BigInteger.valueOf(1000))), PlutusData.unit(), null);
check("Register", builder(tx, issuer) .withRequiredSigners(issuerAddress) .completeAndWait(CONFIRM_TIMEOUT));}RegistryNodeSpecnames the scripts for each role.ProgrammableTokenPolicyRef.named("tutorial")points the mint at the registration in the same transaction. The name exists only inside this transaction. Later mints use the literal policy id.alice.baseAddress()is Alice’s normal address. The library maps it to her smart wallet, so the tokens land there.PlutusData.unit()is the redeemer. This validator ignores its redeemer.withRequiredSignersdeclares the issuer. A script sees only the signers a transaction declares, sotx.extra_signatorieswould otherwise be empty.
Registration spends a node of the shared registry, so it can collide with someone else registering at the same moment. If that happens, run it again.
Step 5: Check the balances
Section titled “Step 5: Check the balances”private void printBalances(String label) { System.out.printf("%-15s alice=%s bob=%s issuer=%s%n", label, balance(alice), balance(bob), balance(issuer));}
private BigInteger balance(Account holder) { String unit = policyId + HexUtil.encodeHexString(ASSET_NAME.getBytes(StandardCharsets.UTF_8)); return value(protocol.getProgrammableBalance(new Address(holder.baseAddress()))).stream() .filter(amount -> unit.equals(amount.getUnit())) .map(Amount::getQuantity) .reduce(BigInteger.ZERO, BigInteger::add);}getProgrammableBalance takes the owner’s normal address and reads their smart wallet. It returns only tokens that are in the registry. Other calls on protocol you may find useful are smartWalletAddress, isProgrammable and getRegistryNode.
After the mint you’ll see:
after mint alice=1000 bob=0 issuer=0Step 6: Try a transfer without approval
Section titled “Step 6: Try a transfer without approval”Alice tries to send 100 tokens to Bob, signing alone:
private void transferWithoutApproval() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(alice.baseAddress()) .transfer(bob.baseAddress(), Amount.asset(policyId, ASSET_NAME, 100), PlutusData.unit());
try { builder(tx, alice).completeAndWait(); } catch (TxBuildException e) { System.out.println("Rejected : " + e.getMessage()); return; } throw new IllegalStateException("The transfer should not have passed without the issuer");}The library declares Alice as a required signer because her wallet is being spent, and that satisfies the protocol. But the token’s transfer logic also runs, finds no issuer signature and fails. Evaluation stops the build, so nothing is submitted and no fee or collateral is at risk.
The failure is thrown as a TxBuildException with this message:
Rejected : Error while evaluating script costIf you have an SLF4J provider on the classpath, the evaluator also logs the reason. tag: "Withdraw" tells you that a withdraw-zero script said no:
AIKEN: RedeemerError { tag: "Withdraw", index: 2, err: Machine(EvaluationFailure, ...) }Step 7: Send an approved transfer
Section titled “Step 7: Send an approved transfer”The same transaction goes through once the issuer approves it. Both parts matter: withRequiredSigners declares the issuer, and withSigner adds the signature.
private void transfer() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(alice.baseAddress()) .transfer(bob.baseAddress(), Amount.asset(policyId, ASSET_NAME, 100), PlutusData.unit());
check("Transfer", builder(tx, alice) .withRequiredSigners(issuerAddress) .withSigner(SignerProviders.signerFrom(issuer)) .completeAndWait(CONFIRM_TIMEOUT));}after transfer alice=900 bob=100 issuer=0Step 8: Seize tokens
Section titled “Step 8: Seize tokens”Now the issuer takes 40 tokens from Bob. Bob doesn’t sign, and he doesn’t need any ADA either.
private void seize() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(issuer.baseAddress()) .thirdPartyTransfer(bob.baseAddress(), issuer.baseAddress(), Amount.asset(policyId, ASSET_NAME, 40), PlutusData.unit());
check("Seize", builder(tx, issuer) .withRequiredSigners(issuerAddress) .completeAndWait(CONFIRM_TIMEOUT));}thirdPartyTransfer takes the holder, the receiver, the amount and a redeemer. The library selects Bob’s UTxOs, spends them under the token’s third-party logic, and keeps whatever isn’t seized in his smart wallet.
Unlike a transfer, this verb declares no signer for you, so withRequiredSigners is what makes the issuer’s signature visible to the script.
after seizure alice=900 bob=60 issuer=40Step 9: Burn tokens
Section titled “Step 9: Burn tokens”The issuer now burns the 40 seized tokens:
private void burn() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(issuer.baseAddress()) .burn(policyId, List.of(new Asset(ASSET_NAME, BigInteger.valueOf(40))), BurnAuthorization.of(PlutusData.unit(), PlutusData.unit()));
check("Burn", builder(tx, issuer).completeAndWait(CONFIRM_TIMEOUT));}A burn has its own verb because it needs two approvals: the transfer logic spends the tokens from the wallet, and the minting logic approves the negative mint. BurnAuthorization carries a redeemer for each. This token uses one script for both roles, and that script runs once, so you pass the same value twice.
There’s no withRequiredSigners here. The issuer is spending their own smart wallet, so the library already declares them as the owner, and that’s the same key the script checks.
final alice=900 bob=60 issuer=0Run it
Section titled “Run it”Here is the complete class, ready to copy. It runs in stages, so that you can try your first mint before going further:
- Run it as it is to register the token and mint 1,000 units to Alice.
- In
run(), comment out the run 1 lines and uncomment the run 2 lines, then run it again to transfer to Bob. Run 2 also shows a transfer being rejected without the issuer’s approval. - Comment out the run 2 lines and uncomment the run 3 lines, then run it again to seize and burn.
IssuerApprovedToken.java
package tutorial;
import com.bloxbean.cardano.aiken.AikenScriptUtil;import com.bloxbean.cardano.aiken.AikenTransactionEvaluator;import com.bloxbean.cardano.client.account.Account;import com.bloxbean.cardano.client.address.Address;import com.bloxbean.cardano.client.address.AddressProvider;import com.bloxbean.cardano.client.address.Credential;import com.bloxbean.cardano.client.api.model.Amount;import com.bloxbean.cardano.client.api.model.Result;import com.bloxbean.cardano.client.backend.api.BackendService;import com.bloxbean.cardano.client.backend.blockfrost.common.Constants;import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService;import com.bloxbean.cardano.client.common.model.Network;import com.bloxbean.cardano.client.common.model.Networks;import com.bloxbean.cardano.client.function.exception.TxBuildException;import com.bloxbean.cardano.client.function.helper.SignerProviders;import com.bloxbean.cardano.client.plutus.blueprint.PlutusBlueprintUtil;import com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion;import com.bloxbean.cardano.client.plutus.spec.BytesPlutusData;import com.bloxbean.cardano.client.plutus.spec.ListPlutusData;import com.bloxbean.cardano.client.plutus.spec.PlutusData;import com.bloxbean.cardano.client.plutus.spec.PlutusScript;import com.bloxbean.cardano.client.programmabletoken.BurnAuthorization;import com.bloxbean.cardano.client.programmabletoken.ProgrammableTokenPolicyRef;import com.bloxbean.cardano.client.programmabletoken.ProgrammableTokenTx;import com.bloxbean.cardano.client.programmabletoken.cip113.Cip113Deployments;import com.bloxbean.cardano.client.programmabletoken.cip113.Cip113ProgrammableTokenService;import com.bloxbean.cardano.client.programmabletoken.cip113.Cip113ProtocolService;import com.bloxbean.cardano.client.programmabletoken.cip113.Cip113Registration;import com.bloxbean.cardano.client.programmabletoken.cip113.tx.RegistryNodeSpec;import com.bloxbean.cardano.client.quicktx.QuickTxBuilder;import com.bloxbean.cardano.client.quicktx.Tx;import com.bloxbean.cardano.client.quicktx.TxResult;import com.bloxbean.cardano.client.quicktx.TxStatus;import com.bloxbean.cardano.client.transaction.spec.Asset;import com.bloxbean.cardano.client.util.HexUtil;
import java.math.BigInteger;import java.nio.charset.StandardCharsets;import java.time.Duration;import java.util.List;
public class IssuerApprovedToken {
/** issuer_approved.issuer_approved.withdraw from plutus.json, before the issuer is applied. */ private static final String COMPILED_CODE = "5895010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264646644b3001" + "3370e900218039baa00189991198008009bac300c300d300d300d300d300d300d300d300d30" + "0a3754601800c601460106ea8004896600200314a115980099b8f375c601800200f14a31330" + "02002300d0014020805a2c8030c020004c020c024004c020004c010dd5004452689b2b200401";
private static final String ASSET_NAME = "TutorialToken";
/** How long to wait for a submitted transaction to land in a block. */ private static final Duration CONFIRM_TIMEOUT = Duration.ofMinutes(5);
/** How long to let the backend's UTxO view catch up after a confirmed transaction. */ private static final Duration SETTLE_TIME = Duration.ofSeconds(60);
private final BackendService backend; private final Network network; private final Account issuer; private final Account alice; private final Account bob;
private final Cip113ProgrammableTokenService tokens; private final Cip113ProtocolService protocol; private final Address issuerAddress;
private PlutusScript logicScript; private Credential logic; private String policyId;
public IssuerApprovedToken(BackendService backend, Cip113ProgrammableTokenService tokens, Network network, Account issuer, Account alice, Account bob) { this.backend = backend; this.network = network; this.issuer = issuer; this.alice = alice; this.bob = bob; this.issuerAddress = new Address(issuer.baseAddress()); this.tokens = tokens; this.protocol = tokens.advanced(); }
public static void main(String[] args) throws Exception { String projectId = System.getenv("BF_PROJECT_ID"); String mnemonic = System.getenv("TUTORIAL_MNEMONIC"); Network network = Networks.preview();
BackendService backend = new BFBackendService(Constants.BLOCKFROST_PREVIEW_URL, projectId);
// One mnemonic, three separate accounts: the issuer and two holders. Account issuer = Account.createFromMnemonic(network, mnemonic, 0, 0); Account alice = Account.createFromMnemonic(network, mnemonic, 1, 0); Account bob = Account.createFromMnemonic(network, mnemonic, 2, 0);
Cip113ProgrammableTokenService tokens = Cip113ProgrammableTokenService.create(backend, Cip113Deployments.PREVIEW);
new IssuerApprovedToken(backend, tokens, network, issuer, alice, bob).run(); }
public void run() throws Exception { prepareLogicScript(); // Needed on every run. It submits nothing.
// Run 1: fund Alice, register the token and mint. // These work once per wallet, so comment them out before run 2. fundAlice(); registerRewardAccount(); registerAndMint(); printBalances("after mint");
// Run 2: uncomment to try a transfer. // transferWithoutApproval(); // transfer(); // printBalances("after transfer");
// Run 3: uncomment to seize and burn, after commenting out run 2. // seize(); // printBalances("after seizure"); // burn(); // printBalances("final"); }
// -- Step: the token's logic script
private void prepareLogicScript() throws Exception { byte[] issuerKeyHash = issuer.hdKeyPair().getPublicKey().getKeyHash();
String applied = AikenScriptUtil.applyParamToScript( ListPlutusData.of(BytesPlutusData.of(issuerKeyHash)), COMPILED_CODE); logicScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(applied, PlutusVersion.v3); logic = Credential.fromScript(logicScript.getPolicyId());
// Nothing on chain has used this script yet, so no backend can serve it. protocol.scripts().register(logicScript);
policyId = value(protocol.derivePolicyId(logic)); System.out.println("Logic script : " + logicScript.getPolicyId()); System.out.println("Policy id : " + policyId); }
private void fundAlice() { Tx tx = new Tx() .payToAddress(alice.baseAddress(), Amount.ada(30)) .from(issuer.baseAddress());
check("Fund alice", new QuickTxBuilder(backend) .compose(tx) .withSigner(SignerProviders.signerFrom(issuer)) .completeAndWait(CONFIRM_TIMEOUT)); }
// -- Step: withdraw-zero needs a registered reward account
private void registerRewardAccount() { String rewardAddress = AddressProvider.getRewardAddress(logic, network).toBech32();
TxResult result = new QuickTxBuilder(backend) .compose(new Tx() .registerStakeAddress(rewardAddress) .from(issuer.baseAddress())) .withSigner(SignerProviders.signerFrom(issuer)) .completeAndWait(CONFIRM_TIMEOUT);
if (!result.isSuccessful() && result.getResponse().contains("StakeKeyRegistered")) { System.out.println("Reward account already registered"); return; } check("Reward acct", result); }
// -- Step: register the token and mint its first supply
private void registerAndMint() { RegistryNodeSpec spec = RegistryNodeSpec.builder() .mintingLogicScript(logic) .transferLogicScript(logic) .thirdPartyTransferLogicScript(logic) .build();
ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(issuer.baseAddress()) .register("tutorial", Cip113Registration.from(spec), PlutusData.unit()) .mint(ProgrammableTokenPolicyRef.named("tutorial"), alice.baseAddress(), List.of(new Asset(ASSET_NAME, BigInteger.valueOf(1000))), PlutusData.unit(), null);
check("Register", builder(tx, issuer) .withRequiredSigners(issuerAddress) .completeAndWait(CONFIRM_TIMEOUT)); }
// -- Step: a transfer without the issuer's signature
private void transferWithoutApproval() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(alice.baseAddress()) .transfer(bob.baseAddress(), Amount.asset(policyId, ASSET_NAME, 100), PlutusData.unit());
try { builder(tx, alice).completeAndWait(); } catch (TxBuildException e) { System.out.println("Rejected : " + e.getMessage()); return; } throw new IllegalStateException("The transfer should not have passed without the issuer"); }
// -- Step: the same transfer, approved
private void transfer() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(alice.baseAddress()) .transfer(bob.baseAddress(), Amount.asset(policyId, ASSET_NAME, 100), PlutusData.unit());
check("Transfer", builder(tx, alice) .withRequiredSigners(issuerAddress) .withSigner(SignerProviders.signerFrom(issuer)) .completeAndWait(CONFIRM_TIMEOUT)); }
// -- Step: seize tokens without the holder's signature
private void seize() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(issuer.baseAddress()) .thirdPartyTransfer(bob.baseAddress(), issuer.baseAddress(), Amount.asset(policyId, ASSET_NAME, 40), PlutusData.unit());
check("Seize", builder(tx, issuer) .withRequiredSigners(issuerAddress) .completeAndWait(CONFIRM_TIMEOUT)); }
// -- Step: burn
private void burn() { ProgrammableTokenTx tx = new ProgrammableTokenTx() .from(issuer.baseAddress()) .burn(policyId, List.of(new Asset(ASSET_NAME, BigInteger.valueOf(40))), BurnAuthorization.of(PlutusData.unit(), PlutusData.unit()));
check("Burn", builder(tx, issuer).completeAndWait(CONFIRM_TIMEOUT)); }
// -- Helpers
private QuickTxBuilder.TxContext builder(Tx tx, Account sender) { return new QuickTxBuilder(backend) .withExtension(tokens.extension()) .compose(tx) .withSigner(SignerProviders.signerFrom(sender)) .withTxEvaluator(new AikenTransactionEvaluator(backend)); }
private void printBalances(String label) { System.out.printf("%-15s alice=%s bob=%s issuer=%s%n", label, balance(alice), balance(bob), balance(issuer)); }
private BigInteger balance(Account holder) { String unit = policyId + HexUtil.encodeHexString(ASSET_NAME.getBytes(StandardCharsets.UTF_8)); return value(protocol.getProgrammableBalance(new Address(holder.baseAddress()))).stream() .filter(amount -> unit.equals(amount.getUnit())) .map(Amount::getQuantity) .reduce(BigInteger.ZERO, BigInteger::add); }
private static void check(String step, TxResult result) { if (!result.isSuccessful()) throw new IllegalStateException(step + " failed: " + result.getResponse()); if (result.getTxStatus() != TxStatus.CONFIRMED) throw new IllegalStateException(step + " not confirmed (" + result.getTxStatus() + "): " + result.getValue()); System.out.printf("%-12s : %s%n", step, result.getValue()); settle(); }
private static void settle() { try { Thread.sleep(SETTLE_TIME.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } }
private static <T> T value(Result<T> result) { if (!result.isSuccessful()) throw new IllegalStateException(result.getResponse()); return result.getValue(); }}On Preview, the three runs print something like this, with your own hashes. Each run starts with the Logic script and Policy id lines, which are the same every time.
Logic script : 88b22cd904b2aee70c4401e1946d7f9dc64f994f2cd78d63506659e7Policy id : 6eea33c0c1b7604ff56e3906ed5b4ebac98791beec17b9d6a6eb30cbFund alice : 31035ba6028e7503a46855b76ee670cb40e9e250d245156c6bfc9dc305363c4cReward acct : d1eb35c75cea0267263af683b753e9f259a2b0278edc45af4baddcdcb5dfff26Register : 1888b7ded91938a781bdaf2b190d0b0d2a7209c6c7266620c35da43276019a76after mint alice=1000 bob=0 issuer=0
Rejected : Error while evaluating script costTransfer : dd17f6d0ab4bd396d0e813f2266681b974ca3f97204c84ad37b34047fc11bcc4after transfer alice=900 bob=100 issuer=0
Seize : bffa140c42094db1409d568f1bcb8f5fe56c68b4046b05a1dc155f0055d34db9after seizure alice=900 bob=60 issuer=40Burn : 2b93d79a3a754975507ded23ff6763bd9ef94af5ec440660722c8c3291957b25final alice=900 bob=60 issuer=0The whole run cost about 7 tADA from the issuer and 1.6 tADA from Alice, on top of the 30 tADA the issuer sends her.
You can look up each transaction hash in a Preview explorer.
Run 1 works once per mnemonic, because the token stays registered. To start again from run 1, create a new wallet. With the pauses, our Preview run took about ten minutes.
Use Scalus instead of Aiken
Section titled “Use Scalus instead of Aiken”Scalus can do both jobs that Aiken Java Binding does here, and it also runs on ARM64 Linux. Replace the aiken-java-binding dependency with org.scalus:scalus-bloxbean-cardano-client-lib_3:1.4.1, then change two places.
Apply the parameter in prepareLogicScript:
PlutusScript unapplied = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(COMPILED_CODE, PlutusVersion.v3);String applied = ScalusScriptUtils.applyParamsToScript(unapplied.getCborHex(), BytesPlutusData.of(issuerKeyHash));logicScript = PlutusV3Script.builder().type("PlutusScriptV3").cborHex(applied).build();Evaluate scripts in builder, with params from backend.getEpochService().getProtocolParameters().getValue():
.withTxEvaluator(new ScalusTransactionEvaluator( params, new DefaultUtxoSupplier(backend.getUtxoService()), new ScriptServiceSupplier(backend.getScriptService())));The parameter gives the same script hash either way, so a token registered with one tool works with the other.
What you built
Section titled “What you built”| Action | Verb | Who signs | Logic that runs |
|---|---|---|---|
| Register and mint | register, mint | Issuer | Minting |
| Transfer | transfer | Holder and issuer | Transfer |
| Seize | thirdPartyTransfer | Issuer | Third-party |
| Burn | burn | Holder (here, the issuer) | Transfer and minting |
The “issuer” requirement comes from your script, not from the protocol. A different script gives you different rules, and the Java code barely changes.
Troubleshooting
Section titled “Troubleshooting”| What you see | Cause | Fix |
|---|---|---|
All inputs are spent. Transaction has probably already been included | The backend returned a UTxO that a confirmed transaction had already spent | Wait and retry, or increase SETTLE_TIME |
... not confirmed (TIMEOUT): <hash> | The transaction didn’t land within CONFIRM_TIMEOUT, and it may still land | Look up the hash in an explorer. Run again once it has landed, using a new wallet if the token is already registered, and increase CONFIRM_TIMEOUT |
No covering registry node for policy ... | The token is already registered, so registerAndMint() can’t run again on this wallet. Comment out the run 1 lines, or create a new wallet. | |
Could not resolve the minting logic script ... | The chain hasn’t revealed your script, and you didn’t hand it over | Call protocol.scripts().register(script) before building |
ConwayWithdrawalsMissingAccounts in the submit response | The script’s reward account isn’t registered | Run Step 3 |
No covering registry node for policy ... | That policy is already registered, usually because you ran the story twice | Skip register, or create a new wallet |
Error while evaluating script cost | A script rejected the transaction, usually because the issuer isn’t declared | Add withRequiredSigners(issuerAddress) |
MissingVKeyWitnessesUTXOW in the submit response | A signer was declared but didn’t sign | Add the matching withSigner(...) |
Not enough plain ADA to fund this transaction safely | The sender’s normal address has too little ADA in ADA-only UTxOs | Send ADA to that address; about 4.5 ADA covered a transfer in testing |
Smart wallet ... does not hold enough | The sender’s smart wallet doesn’t have those tokens | Check getProgrammableBalance |
Limits
Section titled “Limits”- Logic credentials must be scripts. Registrations and updates that name a verification key are refused, so you can’t create a token this library couldn’t operate.
- Unfracking isn’t supported.
unfrack(...)throwsUnsupportedOperationExceptionon every deployment, and none of the public deployments can unfrack. - A third-party transaction acts on one policy, and owner transfers and burns can’t share it.
- One
Txper transaction. Write all programmable-token operations of a transaction in a singleTx, because splitting them acrosscompose(tx1, tx2)is refused. - Each programmable output holds one policy, and inline datums are limited by the deployment’s
max_inline_datum_bytes. - Protocol upgrades and authority changes aren’t authored by this library.
Next steps
Section titled “Next steps”- Give each role its own script. For example, keep transfers open and add a denylist using
globalStateCsonRegistryNodeSpec. - Read the programmable-token README for
thirdPartyBurn,updateRegistry, inline datums and custom deployments. - Store these transactions as YAML with TxPlan. Programmable-token plans go through
tokens.txPlanCodec(), not plainTxPlan.toYaml(). - Run several steps in sequence with TxFlow, registering the extension on the runtime.