Skip to content

This guide shows how to write Ripple Custody policies that control Multi-Purpose Token (MPT) operations on the XRP Ledger, including requiring compliance approval for clawbacks, restricting who can create a new MPT issuance, and enforcing dual control on freeze actions.

Target audience: Integrators and custodians rolling out MPTs who need to enforce approval workflows on issuer and holder actions.

The UI policy builder does not currently expose XRPL operation-level targeting (for example, a selector for MPTokenIssuanceCreate or Clawback). To govern MPT operations, author policies as JSON and submit them via the v0_CreatePolicy intent, or include them in the policies payload at genesis setup.

Prerequisites

  • Ripple Custody v1.32 or later (MPT issuer operations are available from v1.32; holder operations from v1.31).
  • An XRPL account registered in Ripple Custody as the MPT issuer.
  • Familiarity with policy conditions and the v0_CreatePolicy payload structure.
  • A set of user roles defined in your domain that reflects how your organisation separates duties. The examples below use illustrative names like transaction-operator, treasury-operator, compliance, and cfo; substitute the equivalents that already exist in your deployment.

How MPT operations map to policy conditions

Two characteristics of MPTs affect how you write matching conditions:

  1. MPT operations do not have dedicated intent types. Every MPT action — create, mint, burn, freeze, clawback, destroy — is submitted as a v0_CreateTransactionOrder intent. Policies that filter only by intentTypes: ["v0_CreateTransactionOrder"] match every XRPL and EVM transaction, not just MPT ones.
  2. The specific MPT operation is located at context.request.payload.parameters.operation.type. Policy conditions must inspect this path to target an individual operation.

The following table maps each MPT action to the condition expression that identifies it. All expressions assume the intent is a v0_CreateTransactionOrder with payload.parameters.type == "XRPL".

MPT actionoperation.typeAdditional disambiguation
Create issuanceMPTokenIssuanceCreate
Destroy issuanceMPTokenIssuanceDestroy
Freeze (holder or global)MPTokenIssuanceSetoperation.flags.includes('tfMPTLock')
Unfreeze (holder or global)MPTokenIssuanceSetoperation.flags.includes('tfMPTUnlock')
Issuer authorizes a holderMPTokenAuthorizeoperation.holder != null
Holder opts inMPTokenAuthorizeoperation.holder == null
ClawbackClawbackoperation.currency.type == 'MultiPurposeToken'
Mint, burn, or transferPaymentoperation.currency != null && operation.currency.type == 'MultiPurposeToken'
Escrow finishEscrowFinish

Mint, burn, and holder-to-holder transfer all use the same Payment operation with currency.type == "MultiPurposeToken". Direction distinguishes them:

  • Mint: source account is the issuer.
  • Burn: destination is the issuer (tokens sent back to the issuer are automatically burned).
  • Transfer: source and destination are both holders.

Compare context.request.payload.accountId (the source) against the known issuer account UUID to disambiguate in conditions.

The currency field is optional on the XRPL Payment operation. For native XRP payments it is omitted in your request, but the policy scripting engine sees it as "currency": null — the engine re-serializes the parsed domain object before evaluation, and absent optional fields are emitted as null rather than dropped. Accessing operation.currency.type then throws on null. Use operation.currency != null to guard the access — this catches both the null and undefined cases. Throwing inside a condition causes the whole intent to fail with PolicyScriptingExecutionFailed — not a quiet non-match. Guard every optional nested property access with != null.

For full payload examples of each operation, see Multi-purpose tokens.

Example policies

The policies below are illustrative examples, not a prescribed configuration. Your deployment will have its own role taxonomy, rank ladder, risk appetite, and account structure — adapt each example accordingly.

Before deploying any policy from this page, review and replace the following. None of these values are platform defaults — they vary by deployment:

Illustrative in this guideWhat to substituteHow to discover it
Role names (transaction-operator, compliance, treasury-operator, cfo, etc.)The role names your deployment already usesGET /v1/domains/{domainId}/users/roles
Quorum counts and workflow shapeThe number of approvers and the approval chain your control framework requiresYour internal policy-design standard
Ranks (760795)Ranks that slot correctly into your existing ladder, below breakglass (900–999) and above standard transaction rulesGET /v1/domains/{domainId}/policies to see the current ladder
Pinned account UUID (11111111-2222-3333-4444-555555555555)The real UUID of the account you intend to permit as the issuerGET /v1/domains/{domainId}/accounts
Large-payment threshold (1000000n)A threshold that reflects your MPT's assetScale and your risk appetiteassetScale is set at issuance creation; pair it with a display-unit amount you care about
Scope (Self / SelfAndDescendants)The scope that matches your domain hierarchy and who you want the policy to coverYour domain model

Once adapted, install each policy with lock: "Locked" first, unlock it, submit a representative intent from a test account, and confirm via GET /v1/domains/{domainId}/intents/{intentId}.state.progressPerPolicy[].policyReference.id that the intended policy is matched before rolling out further.

Require compliance approval for clawback

Clawback lets an issuer reclaim tokens from a holder without consent. One common pattern is to require the submitting operator plus two compliance approvers on every clawback; adjust the roles and quorum to fit your approval policy.

{
  "payload": {
    "id": "{{policy_id}}",
    "alias": "mpt-clawback-requires-compliance",
    "rank": 780,
    "intentTypes": ["v0_CreateTransactionOrder"],
    "scope": "Self",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "type": "Expression",
      "expression": "context.request.payload.parameters.type == 'XRPL' && context.request.payload.parameters.operation != null && context.request.payload.parameters.operation.type == 'Clawback' && context.request.payload.parameters.operation.currency.type == 'MultiPurposeToken'"
    },
    "workflow": [
      {
        "type": "And",
        "left":  { "type": "RoleQuorum", "role": "transaction-operator", "quorum": 1 },
        "right": { "type": "RoleQuorum", "role": "compliance",           "quorum": 2 }
      }
    ],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  }
}

Restrict MPT issuance creation to a specific account

If your policy is that only one or two operational accounts should ever create new MPT issuances, you can pin creation to a specific account UUID. The example below pins to a single UUID and requires treasury plus compliance approval; replace the placeholder UUID with the issuer account in your deployment.

{
  "payload": {
    "id": "{{policy_id}}",
    "alias": "mpt-issuance-create-pinned-account",
    "rank": 790,
    "intentTypes": ["v0_CreateTransactionOrder"],
    "scope": "SelfAndDescendants",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "type": "Expression",
      "expression": "context.request.payload.parameters != null && context.request.payload.parameters.operation != null && context.request.payload.parameters.operation.type == 'MPTokenIssuanceCreate' && context.request.payload.accountId == '11111111-2222-3333-4444-555555555555'"
    },
    "workflow": [
      {
        "type": "And",
        "left":  { "type": "RoleQuorum", "role": "treasury-operator", "quorum": 1 },
        "right": { "type": "RoleQuorum", "role": "compliance",        "quorum": 2 }
      }
    ],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  }
}

To reject MPT issuance creation from any other account, pair the policy above with a lower-ranked catch-all that has an empty workflow array. An empty workflow auto-rejects every matching intent.

{
  "payload": {
    "id": "{{policy_id}}",
    "alias": "mpt-issuance-create-reject-other-accounts",
    "rank": 770,
    "intentTypes": ["v0_CreateTransactionOrder"],
    "scope": "SelfAndDescendants",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "type": "Expression",
      "expression": "context.request.payload.parameters != null && context.request.payload.parameters.operation != null && context.request.payload.parameters.operation.type == 'MPTokenIssuanceCreate'"
    },
    "workflow": [],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  }
}

The pinned-account policy (rank 790) is evaluated first and applies to the authorized account. The catch-all (rank 770) applies only when the first policy does not match, and rejects the intent.

Require dual control on freeze and unfreeze

Freezing a holder or the entire issuance can halt all transfers of the token, so dual control is a common control point. The example below applies a maker-checker workflow to both freeze and unfreeze actions in one policy; you can split into two policies if the two actions merit different approval paths.

{
  "payload": {
    "id": "{{policy_id}}",
    "alias": "mpt-freeze-unfreeze-dual-control",
    "rank": 785,
    "intentTypes": ["v0_CreateTransactionOrder"],
    "scope": "Self",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "type": "Expression",
      "expression": "context.request.payload.parameters.operation != null && context.request.payload.parameters.operation.type == 'MPTokenIssuanceSet' && (context.request.payload.parameters.operation.flags.includes('tfMPTLock') || context.request.payload.parameters.operation.flags.includes('tfMPTUnlock'))"
    },
    "workflow": [
      { "type": "RoleQuorum", "role": "transaction-operator", "quorum": 1 },
      { "type": "RoleQuorum", "role": "compliance",           "quorum": 1 }
    ],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  }
}

To apply different workflows for freeze and unfreeze, create two policies with distinct conditions: one that checks flags.includes('tfMPTLock'), and one that checks flags.includes('tfMPTUnlock').

Require extra approval for large MPT payments

Mint, burn, and holder-to-holder transfers all use the Payment operation. The example below shows how to add an amount-based threshold: any MPT payment at or above 1000000n base units requires CFO approval. The threshold value, the role, and the quorum are illustrative — pick values that match your own risk appetite and role taxonomy. Guard operation.currency first because it is optional on the Payment schema.

{
  "payload": {
    "id": "{{policy_id}}",
    "alias": "mpt-large-payment-cfo-approval",
    "rank": 760,
    "intentTypes": ["v0_CreateTransactionOrder"],
    "scope": "Self",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "type": "Expression",
      "expression": "context.request.payload.parameters.operation != null && context.request.payload.parameters.operation.type == 'Payment' && context.request.payload.parameters.operation.currency != null && context.request.payload.parameters.operation.currency.type == 'MultiPurposeToken' && BigInt(context.request.payload.parameters.operation.amount) >= 1000000n"
    },
    "workflow": [
      {
        "type": "And",
        "left":  { "type": "RoleQuorum", "role": "transaction-operator", "quorum": 1 },
        "right": { "type": "RoleQuorum", "role": "cfo",                  "quorum": 1 }
      }
    ],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  }
}

MPT amount values are serialized as strings and can exceed 2^53. Wrap the value in BigInt(...) and use the n suffix on the threshold literal (1000000n) so the comparison is lossless. Comparing as plain JavaScript numbers silently loses precision.

If the token's assetScale is 6, the threshold 1000000n corresponds to one display unit. Adjust the threshold to match the issuance's decimal precision.

The Payment operation uses the field name amount. The Clawback operation uses value. When combining thresholds across operations, use the correct field per operation type.

Require a high approval bar for destroy issuance

MPTokenIssuanceDestroy permanently retires an MPT once supply reaches zero, so deployments typically set a high approval bar for it. The example below requires treasury plus two compliance approvers; adjust to fit your control framework.

{
  "payload": {
    "id": "{{policy_id}}",
    "alias": "mpt-destroy-issuance-high-bar",
    "rank": 795,
    "intentTypes": ["v0_CreateTransactionOrder"],
    "scope": "SelfAndDescendants",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "type": "Expression",
      "expression": "context.request.payload.parameters.operation != null && context.request.payload.parameters.operation.type == 'MPTokenIssuanceDestroy'"
    },
    "workflow": [
      {
        "type": "And",
        "left":  { "type": "RoleQuorum", "role": "treasury-operator", "quorum": 1 },
        "right": { "type": "RoleQuorum", "role": "compliance",        "quorum": 2 }
      }
    ],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  }
}

Common pitfalls

Generic transaction policies outranking MPT policies

Policies are evaluated in descending rank order. If a generic v0_CreateTransactionOrder policy has a higher rank than the MPT-specific policies, it captures the intent first and the MPT rules never apply. Always give MPT-specific policies a higher rank than the generic transaction policy.

Unguarded property access throws and fails the intent

If any part of the path in a condition resolves to null at evaluation time, accessing a nested property throws a TypeError and the intent terminates with status: "Failed" and error code PolicyScriptingExecutionFailed. This is not a quiet non-match — the intent is rejected entirely and never reaches later policies or execution. Guard every optional nested access with != null:

context.request.payload.parameters.operation != null &&
context.request.payload.parameters.operation.type == 'Clawback'

Do not use hasOwnProperty for these guards. The policy scripting engine re-serializes the parsed domain object before evaluation, and the serializer emits every optional field as "key": null rather than omitting it. The key is always present on the JSON the condition sees, so hasOwnProperty always returns true and provides no protection. Use != null consistently — it catches both the null and undefined cases.

Integer precision for amounts

MPT amount and value fields are serialized as strings and can exceed JavaScript's safe integer range. Use BigInt(...) around the field and the n suffix on literals in the condition. Comparing as plain numbers silently loses precision for large values.

Validating policies before rollout

Dry-run validates the JSON schema and that the condition parses as JavaScript. It does not validate that path expressions resolve against a real intent and it does not simulate workflow: [] rejection. Runtime errors surface only when an actual v0_CreateTransactionOrder intent is submitted.

To validate behaviour safely:

  1. Deploy the policy with lock: "Locked" initially so it does not affect production traffic.
  2. Unlock the policy and submit a representative MPT intent from a test account.
  3. Fetch GET /v1/domains/{domainId}/intents/{intentId} and inspect state.status (Rejected / Executed / Open) and state.progressPerPolicy[].policyReference.id to confirm the expected policy matched.
  4. If state.status is Failed with error.code == "PolicyScriptingExecutionFailed", the condition threw at runtime — add or tighten a != null guard on the nested access that resolved to null.