# Govern MPTs with policies

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`](/products/custody/v1.34/api/environment/policy/create) intent, or include them in the `policies` payload at [genesis setup](/products/custody/v1.34/deployment/install/installation#policies).

## 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](/products/custody/v1.34/api/environment/policy/conditions) and the [`v0_CreatePolicy` payload structure](/products/custody/v1.34/api/environment/policy/create).
- 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 action | `operation.type` | Additional disambiguation |
|  --- | --- | --- |
| Create issuance | `MPTokenIssuanceCreate` | — |
| Destroy issuance | `MPTokenIssuanceDestroy` | — |
| Freeze (holder or global) | `MPTokenIssuanceSet` | `operation.flags.includes('tfMPTLock')` |
| Unfreeze (holder or global) | `MPTokenIssuanceSet` | `operation.flags.includes('tfMPTUnlock')` |
| Issuer authorizes a holder | `MPTokenAuthorize` | `operation.holder != null` |
| Holder opts in | `MPTokenAuthorize` | `operation.holder == null` |
| Clawback | `Clawback` | `operation.currency.type == 'MultiPurposeToken'` |
| Mint, burn, or transfer | `Payment` | `operation.currency != null && operation.currency.type == 'MultiPurposeToken'` |
| Escrow finish | `EscrowFinish` | — |


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](/products/custody/v1.34/api/accounting/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 guide | What to substitute | How to discover it |
|  --- | --- | --- |
| Role names (`transaction-operator`, `compliance`, `treasury-operator`, `cfo`, etc.) | The role names your deployment already uses | `GET /v1/domains/{domainId}/users/roles` |
| Quorum counts and workflow shape | The number of approvers and the approval chain your control framework requires | Your internal policy-design standard |
| Ranks (`760`–`795`) | Ranks that slot correctly into your existing ladder, below breakglass (900–999) and above standard transaction rules | `GET /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 issuer | `GET /v1/domains/{domainId}/accounts` |
| Large-payment threshold (`1000000n`) | A threshold that reflects your MPT's `assetScale` and your risk appetite | `assetScale` 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 cover | Your 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.


```json
{
  "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.


```json
{
  "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.


```json
{
  "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.


```json
{
  "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.


```json
{
  "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.


```json
{
  "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`:


```javascript
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`.


## Related topics

- [Multi-purpose tokens concepts](/products/custody/v1.34/concepts/multi-purpose-tokens) — MPT lifecycle, flags, and on-chain metadata.
- [Multi-purpose tokens (API)](/products/custody/v1.34/api/accounting/multi-purpose-tokens) — Payload reference for each MPT operation.
- [Policy conditions](/products/custody/v1.34/api/environment/policy/conditions) — `context` object and JavaScript expression syntax.
- [Create policies](/products/custody/v1.34/api/environment/policy/create) — `v0_CreatePolicy` payload structure.
- [Policy examples](/products/custody/v1.34/get-started/design/policies/examples) — Worked policy patterns for other intent types.
- Multi-purpose tokens (XRPL documentation) — Protocol-level reference.