> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Quorum policies

> Require approvals before a Business Account wallet can sign a message or transaction.

> [!NOTE]
>
> Signing quorum currently covers message signing on all supported chains and transaction signing on EVM and Solana. Bitcoin transactions are not covered. For the quorum model itself, the approval lifecycle, and the requirement fields, see [Approvals](https://docs.dynamic.xyz/docs/business-accounts/approvals.md#approvals-for-signing).

Before this: create and initialize a Dynamic client (see [Creating a Dynamic Client](https://docs.dynamic.xyz/docs/javascript/reference/client/create-dynamic-client.md), [Initializing the Dynamic Client](https://docs.dynamic.xyz/docs/javascript/reference/client/initialize-dynamic-client.md)).

Governance requirements apply to account changes instead of signing. See [Governance](https://docs.dynamic.xyz/docs/javascript/reference/business-accounts/governance.md) for actions such as adding a member or transferring ownership.

`approvalRequirements` is a `PolicyRules` field. It composes with other rules in the same policy layer and uses the [Account-Layer, Wallet-Layer, and Signer-Layer](https://docs.dynamic.xyz/docs/javascript/reference/business-accounts/policies/overview.md) scope of that layer.

The policy layer can further restrict the applicable chain and chain IDs.

> [!NOTE]
>
> The rule below is a legacy rule written with `createPolicy`. A layer holds legacy rules or [declarative rules](https://docs.dynamic.xyz/docs/embedded-wallets/mpc/policies/declarative-rules.md), never both. To write an approval rule as a declarative rule, whose own condition decides when approval applies, see [Approval rules](https://docs.dynamic.xyz/docs/javascript/reference/business-accounts/policies/declarative-rules.md#approval-rules). A declarative approval rule goes through the same signing flow described on this page.

## Create a transaction quorum rule

The following rule requires one approval from a Business Account member with the `admin` role for native-token transfers of at least 1 ETH on Ethereum mainnet.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import { createPolicy } from '@dynamic-labs-sdk/client/waas';
> import { WaasChainEnum } from '@dynamic-labs/sdk-api-core';
>
> await createPolicy({
>   scope: { businessAccountId: 'ba_123' },
>   chain: WaasChainEnum.Evm,
>   chainIds: [1],
>   rules: {
>     approvalRequirements: [
>       {
>         v: 1,
>         id: 'large-native-transfer',
>         eligibleApproverRoles: ['admin'],
>         requiredApprovals: 1,
>         when: {
>           minValue: {
>             amount: '1000000000000000000',
>           },
>         },
>       },
>     ],
>   },
> });
> ```

List one or more built-in roles such as `admin`, or custom Business Account roles. A member holding any listed role can approve. A value such as `anyAdmin` works only when the Business Account defines a custom role with that exact name.

> [!WARNING]
>
> Set `eligibleApproverRoles` explicitly. If you omit it, any verified approver role can satisfy the requirement. The initiator never counts toward `requiredApprovals`.

### Requirement fields

| Field | Type | Description |
| - | - | - |
| `v` | `1` | The requirement schema version. |
| `id` | `string` | A stable ID for the requirement within the policy layer. |
| `eligibleApproverRoles` | `string[]` | The Business Account roles whose members may approve. A holder of any listed role counts. Specify this field to restrict approver eligibility. |
| `mandatoryApproverRoles` | `string[]` | The Business Account roles that must each be represented among the approvals. |
| `requiredApprovals` | `number` | The number of approvals required. The initiator never counts. The rule needs more eligible members than this number, or `createPolicy` refuses it with `governance_unsatisfiable`. See [Requirements and roles](https://docs.dynamic.xyz/docs/business-accounts/approvals.md#requirements-and-roles). |
| `completeWithinSeconds` | `number` | The time for a collected-approval proposal to remain valid. The default and maximum are 30 days (`2592000` seconds). |
| `when` | `object` | Conditions that limit when the requirement applies. Omit it for an unconditional requirement. |

`getPolicy` returns `approvalRequirements` with the same authoring shape. Approval progress is reported during signing. It is not part of the policy definition.

## Limit a rule by transaction value

Use `when.minValue` to apply the requirement at or above a transaction value. Conditions in the same `when` object are combined with AND logic.

* `amount` is an integer string in the asset's smallest unit.
* Omit `asset` for the chain's native token.
* An asset address selects that token.

> [!WARNING]
>
> Do not use `when.initiatorRole` or `when.actionTypes` to narrow a production rule. These fields are accepted but are not resolved during evaluation. A requirement that sets either field applies more broadly instead of being limited by that field.

## The signing intent

Every quorum-gated signing request produces a `SigningIntent`: a signed, complete description of the operation that approvers review and that the resume step executes. The SDK prepares and signs it automatically when you initiate signing, and it is stored on the proposal so approvers see the same object the initiator signed.

### Intent properties

| Field | Type | Description |
| - | - | - |
| `actionType` | `'waasSigningQuorumApprove'` | The governed action the intent belongs to. Always this literal for signing intents. |
| `intentId` | `string` | A unique ID for the intent. |
| `businessAccountId` | `string` | The Business Account whose quorum must approve the operation. |
| `initiatorUserId` | `string` | The member who initiated the signing request. The initiator can never approve their own intent. |
| `accountAddress` | `string` | The wallet address that will sign. |
| `createdAt` | `string` | The ISO timestamp at which the intent was prepared. |
| `completeBy` | `string` | Optional ISO timestamp after which the proposal can no longer complete. |
| `operation` | `object` | The exact operation being approved. Its shape depends on `operation.kind`. |
| `limits` | `object` | Gas and fee ceilings. Present only for EVM transaction intents. |
| `lifetime` | `{ refreshable }` | Names the one chain value replaced at resume time. See [Refreshable fields](#refreshable-fields). |

### Operation kinds

`operation.kind` discriminates the union and determines the remaining fields:

| `kind` | Additional fields | Chain |
| - | - | - |
| `'message'` | `message`, `payload` | `chainName` is any supported chain |
| `'typedData'` | `typedData` (EIP-712 JSON string), `payload` | `chainName` is `'EVM'` |
| `'raw'` | `payload` (digest), `rawSign` (`payload` preimage, `hashFunction`, optional `prefix`) | `chainName` is any supported chain |
| `'transaction'` | `chainId`, `from`, `to`, `value`, `data`, `nonce` | `chainName` is `'EVM'` |
| `'transaction'` | `chainId`, `messageVersion`, `feePayer`, `header`, `staticAccountKeys`, `instructions` | `chainName` is `'SVM'` |

For `message`, `typedData`, and `raw` intents, `payload` is the exact prepared payload the signature covers. For EVM `transaction` intents, `limits` instead carries the `maxGas`, `maxFeePerGas`, `maxPriorityFeePerGas`, and `maxTotalFee` ceilings described in [Transaction fields the intent binds](#transaction-fields-the-intent-binds). A Solana `transaction` intent carries the compiled message itself, described in [Solana transactions](#solana-transactions).

## Complete a quorum-gated signing operation

The signing-quorum flow has three stages:

1. The initiator attempts to sign. The SDK prepares and signs an operation-bound intent automatically. A member can instead open the proposal directly without attempting to sign, described in [Propose an operation without signing](#propose-an-operation-without-signing).
2. Other eligible Business Account members review and approve the resulting proposal.
3. After every requirement is satisfied, the initiator resumes the proposal to produce the signature.

### 1. Start signing

Use a Business Account wallet with its standard signing methods, such as `signMessage`, `signRawMessage`, `signTypedData`, or `signSerializedTransaction`. When a matching quorum rule requires approval, the call rejects with `SigningConsentRequiredError`. Save its `proposalId` so you can find the proposal later.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import { signMessage } from '@dynamic-labs-sdk/client';
> import { isSigningConsentRequiredError } from '@dynamic-labs-sdk/client/waas';
>
> try {
>   const { signature } = await signMessage({
>     message: 'Hello from signing quorum',
>     walletAccount,
>   });
> } catch (error) {
>   if (!isSigningConsentRequiredError(error)) {
>     throw error;
>   }
>
>   console.log('Proposal awaiting approval:', error.proposalId);
> }
> ```

The error's `reason` tells you what consent is still missing:

* `APPROVALS_REQUIRED`: a proposal opened and needs other members' approvals. The error carries `proposalId` and `approvalRequirements`.
* `INTENT_REQUIRED`: the operation's intent still needs your session-key signature. This is rare because the SDK signs the intent automatically, but it can occur when the local client cannot produce the signature.

For `INTENT_REQUIRED`, sign the returned intent and retry the same call with the signed `approvalQuorum`. Pass the operation's stored payload as `preparedPayload` for message, typed data, and raw signing so the intent is checked against your request before it is signed.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import { getDefaultClient } from '@dynamic-labs-sdk/client';
> import {
>   isSigningConsentRequiredError,
>   signSigningQuorumIntent,
> } from '@dynamic-labs-sdk/client/waas';
> import { getWaasWalletProviderFromWalletAccount } from '@dynamic-labs-sdk/client/waas/core';
>
> const message = 'Hello from signing quorum';
> const waasProvider = getWaasWalletProviderFromWalletAccount(
>   { walletAccount },
>   getDefaultClient()
> );
> if (!waasProvider.signMessage) {
>   throw new Error('The wallet provider cannot sign messages.');
> }
>
> try {
>   await waasProvider.signMessage({ message, walletAccount });
> } catch (error) {
>   if (
>     !isSigningConsentRequiredError(error) ||
>     error.reason !== 'INTENT_REQUIRED'
>   ) {
>     throw error;
>   }
>
>   const { intent } = error;
>   if (!intent || intent.operation.kind !== 'message') {
>     throw error;
>   }
>
>   const approvalQuorum = await signSigningQuorumIntent({
>     intent,
>     preparedPayload: intent.operation.payload,
>     request: {
>       accountAddress: walletAccount.address,
>       businessAccountId,
>       chainName: 'EVM',
>       initiatorUserId: intent.initiatorUserId,
>       kind: 'message',
>       message,
>     },
>   });
>
>   await waasProvider.signMessage({
>     approvalQuorum,
>     message,
>     walletAccount,
>   });
> }
> ```

Every chain's WaaS signing methods accept `approvalQuorum` for this retry, including `signPsbt` on Bitcoin and `signSerializedTransaction` on EVM. Omit it on the first call: populate it only in response to an `INTENT_REQUIRED` outcome, with the exact intent that was signed.

### Propose an operation without signing

Any Business Account member can open a signing proposal directly with `createBusinessAccountSigningProposal`, including a member who holds no key share, such as a viewer. No signing attempt runs and the member needs no wallet share: the SDK prepares the intent from `operation`, signs it with the caller's session key, and files the proposal. This suits flows where a non-signer drafts an operation for the quorum to review and a signer later resumes it.

`operation` accepts the same kinds the signing methods carry: `message`, `raw`, `typedData`, `eip7702Auth`, or `transaction`. `accountAddress` is the wallet's chain address, readable as `publicKey` on the wallet summaries `getBusinessAccount` returns. Pass `completeBy` to set the proposal's deadline; otherwise the requirement's completion window applies.

The wallet's policy is evaluated against the intent when the proposal is filed, so the call fails when no quorum rule covers the operation. It returns the proposal's ID, the `approvalRequirement` its approvals must still satisfy, and the stored intent. A retry that finds the identical proposal already open returns that proposal instead of opening a duplicate.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import { createBusinessAccountSigningProposal } from '@dynamic-labs-sdk/client/waas';
> import { WaasChainEnum } from '@dynamic-labs/sdk-api-core';
>
> const { proposalId, approvalRequirement } =
>   await createBusinessAccountSigningProposal({
>     accountAddress: wallet.publicKey,
>     businessAccountId: 'ba_123',
>     chain: WaasChainEnum.Evm,
>     operation: {
>       kind: 'message',
>       message: 'Transfer 100 USDC to vendor',
>     },
>     walletId: wallet.id,
>   });
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import { useCreateBusinessAccountSigningProposal } from '@dynamic-labs-sdk/react-hooks';
> import { WaasChainEnum } from '@dynamic-labs/sdk-api-core';
>
> const { mutate: propose, isPending } =
>   useCreateBusinessAccountSigningProposal();
>
> return (
>   <button
>     disabled={isPending}
>     onClick={() =>
>       propose({
>         accountAddress: wallet.publicKey,
>         businessAccountId,
>         chain: WaasChainEnum.Evm,
>         operation: {
>           kind: 'message',
>           message: 'Transfer 100 USDC to vendor',
>         },
>         walletId: wallet.id,
>       })
>     }
>   >
>     Propose transfer
>   </button>
> );
> ```

The proposal then follows the same lifecycle as one opened by a sign attempt: members approve it, and a member holding a signer key share resumes it, as in [Approve the proposal](#2-approve-the-proposal) and [Resume the approved operation](#3-resume-the-approved-operation).

### Transaction fields the intent binds

For an EVM transaction, approvers consent to the stable fields of the operation: `from`, `to`, `value`, `data`, `chainId`, and `nonce`. Those fields cannot change between the approved intent and the signed transaction.

Only the gas and fee values may move, because a proposal can sit pending for as long as its completion window while gas prices drift. The intent carries four ceilings that bound how much the signed transaction may spend on gas:

| Field | Description |
| - | - |
| `maxGas` | Upper bound on the transaction's gas limit. |
| `maxFeePerGas` | Upper bound on the EIP-1559 max fee per gas. |
| `maxPriorityFeePerGas` | Upper bound on the EIP-1559 priority fee per gas. |
| `maxTotalFee` | Upper bound on `gas * maxFeePerGas`, independent of the others. |

When you initiate signing, `signSerializedTransaction` defaults these limits to the values your unsigned transaction carries. Pass `refreshLimits` to approve wider ceilings up front, for example padded estimates, so the resumed signature still fits current network conditions after the proposal is approved:

```javascript theme={"system"}
import { signSerializedTransaction } from '@dynamic-labs-sdk/client/waas';

await signSerializedTransaction({
  chainId: '1',
  refreshLimits: {
    maxFeePerGas: (fees.maxFeePerGas * 2n).toString(),
    maxGas: (gas * 120n / 100n).toString(),
    maxPriorityFeePerGas: (fees.maxPriorityFeePerGas * 2n).toString(),
    maxTotalFee: (gas * 120n / 100n * fees.maxFeePerGas).toString(),
  },
  serializedTransaction,
  walletAccount,
});
```

At resume time the transaction is rebuilt from the approved operation with gas and fee values set at the approved limits, and wallet-service checks the transaction against those limits again. The approvers' consent covers the operation's identity and its spending ceiling, never a different destination, value, or calldata.

### Solana transactions

Start a Solana transaction by calling `signTransaction` from `@dynamic-labs-sdk/solana` with a Business Account wallet. The SDK prepares and signs the intent automatically, and the call rejects with `SigningConsentRequiredError` when a quorum rule requires approval, as it does for EVM.

```javascript theme={"system"}
import { isSigningConsentRequiredError } from '@dynamic-labs-sdk/client/waas';
import { signTransaction } from '@dynamic-labs-sdk/solana';

// `transaction` is an unsigned @solana/web3.js transaction whose fee payer is the wallet.
try {
  await signTransaction({ transaction, walletAccount });
} catch (error) {
  if (!isSigningConsentRequiredError(error)) {
    throw error;
  }

  console.log('Proposal awaiting approval:', error.proposalId);
}
```

For a Solana transaction, approvers consent to the compiled message except its recent blockhash. The intent's `operation` carries the fee payer, the message version (`'legacy'` or `'v0'`), the message header, the static account keys, and every instruction with its program, accounts, and hex-encoded data. Compute-unit limits and priority fees are instructions, so they are bound too, and none of these can change between approval and signing.

The SDK refuses to prepare an intent for a message it cannot bind completely. The message must be complete and unsigned, the wallet must be its fee payer, and it must not use address lookup tables or a durable nonce.

### Refreshable fields

A proposal can wait for approvals for as long as its completion window, which is far longer than some chain values stay valid. If the intent bound those values at initiation, an approved proposal could become impossible to sign. The intent instead carries a signed `lifetime` marker that names the one value the SDK replaces with a current value when you resume. Because the marker is stored in the intent, an approver who inspects it can see which value will change.

| Chain | `lifetime.refreshable` | Value refreshed at resume | Why it must refresh |
| - | - | - | - |
| Solana | `'blockhash'` | The recent blockhash | A Solana transaction is valid only while its blockhash is recent, which is roughly the last 150 blocks. A blockhash read at initiation has expired long before approvals finish. |
| EVM | `'nonce'` | The account nonce | A nonce fixed at initiation is wrong once the account sends another transaction while the proposal waits. A stale nonce is rejected or leaves a gap that stalls later transactions. |

Every Solana transaction intent carries `lifetime: { refreshable: 'blockhash' }`. An EVM intent carries `lifetime: { refreshable: 'nonce' }` only when it was signed with a refreshable nonce. The intents that `signSerializedTransaction` prepares pin the nonce and have no `lifetime`. Message, typed data, raw, and EIP-7702 intents never have one.

When you resume, the SDK reads the replacement value from the network the intent was approved for. For Solana it fetches the latest blockhash. For an EVM intent with a refreshable nonce it reads the account's pending transaction count, so the nonce follows any transaction the account already sent. The SDK then rebuilds the transaction from the approved operation and signs it. The refreshed value is the only difference from what was approved, apart from EVM gas and fee values that stay within the approved limits. If the active network differs from the intent's `chainId`, the resume throws `SigningProposalNetworkMismatchError` instead of reading a value from the wrong chain.

EVM gas and fee values are separate from `lifetime`. They are the `limits` ceilings described in [Transaction fields the intent binds](#transaction-fields-the-intent-binds), and Solana intents have no `limits`.

### 2. Approve the proposal

An eligible Business Account member other than the initiator approves the proposal from their own authenticated session.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import {
>   approveBusinessAccountProposal,
>   listBusinessAccountProposalsAwaitingMyApproval,
> } from '@dynamic-labs-sdk/client/waas';
>
> const proposals =
>   await listBusinessAccountProposalsAwaitingMyApproval();
> const proposal = proposals.find(({ id }) => id === proposalId);
>
> if (!proposal) {
>   throw new Error('Proposal is not awaiting this member\'s approval.');
> }
>
> const updated = await approveBusinessAccountProposal({
>   businessAccountId: proposal.businessAccountId,
>   proposal,
> });
>
> const ready = updated.approvalRequirements?.every(
>   ({ satisfied }) => satisfied,
> );
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import {
>   useApproveBusinessAccountProposal,
>   useListBusinessAccountProposalsAwaitingMyApproval,
> } from '@dynamic-labs-sdk/react-hooks';
>
> const { data: proposals } =
>   useListBusinessAccountProposalsAwaitingMyApproval();
> const { mutate: approve, isPending } =
>   useApproveBusinessAccountProposal();
>
> return proposals?.map((proposal) => (
>   <button
>     disabled={isPending}
>     key={proposal.id}
>     onClick={() =>
>       approve({
>         businessAccountId: proposal.businessAccountId,
>         proposal,
>       })
>     }
>   >
>     Approve {proposal.actionType}
>   </button>
> ));
> ```

The `satisfied` value is authoritative. Do not infer readiness from `approved >= required` because a requirement can also require specific roles.

An approver can inspect the operation before approving it. `getBusinessAccountSigningIntent` reads the signed intent stored on a signing proposal, so your UI can show the message, typed data, or transaction the quorum is consenting to.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import { getBusinessAccountSigningIntent } from '@dynamic-labs-sdk/client/waas';
>
> const stored = getBusinessAccountSigningIntent({ proposal });
>
> if (stored.status === 'available') {
>   const { operation } = stored.intent;
>   console.log(operation.kind, operation.chainName);
> }
> ```

The helper returns `status: 'unsupported'` for proposals that are not signing operations and `status: 'mismatch'` when the stored intent does not match its proposal.

### 3. Resume the approved operation

Read the pending proposal again, verify that every requirement is satisfied, and resume it with the same Business Account wallet. `resumeSigningProposal` consumes the proposal and returns the signature. For an EVM transaction intent the signature is the serialized signed transaction, which you then broadcast yourself. For a Solana transaction intent the result is the hex-encoded signature alone, computed over a message that uses the latest blockhash the SDK fetched at resume time. The SDK does not return that blockhash, so the result is not a broadcastable Solana transaction.

> [tab] JavaScript
>
> ```javascript theme={"system"}
> import {
>   getBusinessAccountSigningIntent,
>   listBusinessAccountProposals,
>   resumeSigningProposal,
> } from '@dynamic-labs-sdk/client/waas';
> const proposals = await listBusinessAccountProposals({
>   businessAccountId: 'ba_123',
>   status: 'pending',
> });
> const proposal = proposals.find(({ id }) => id === proposalId);
>
> if (
>   !proposal ||
>   new Date(proposal.completeBy) <= new Date() ||
>   !proposal.approvalRequirements?.every(({ satisfied }) => satisfied)
> ) {
>   throw new Error('The signing proposal is not ready.');
> }
>
> const { signature } = await resumeSigningProposal({
>   proposal,
>   walletAccount,
> });
>
> // For a transaction intent the signature is the signed transaction.
> const transactionHash = await publicClient.sendRawTransaction({
>   serializedTransaction: signature,
> });
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import {
>   useListBusinessAccountProposals,
>   useResumeSigningProposal,
> } from '@dynamic-labs-sdk/react-hooks';
>
> const { data: pending } = useListBusinessAccountProposals({
>   businessAccountId,
>   status: 'pending',
> });
> const { mutate: resume, isPending } = useResumeSigningProposal();
>
> const ready = pending?.filter(
>   (proposal) =>
>     new Date(proposal.completeBy) > new Date() &&
>     proposal.approvalRequirements?.every(({ satisfied }) => satisfied)
> );
>
> return ready?.map((proposal) => (
>   <button
>     disabled={isPending}
>     key={proposal.id}
>     onClick={() => resume({ proposal, walletAccount })}
>   >
>     Finish signing {proposal.actionType}
>   </button>
> ));
> ```

`resumeSigningProposal` verifies that the proposal is pending and fully approved and that the selected wallet belongs to the proposal's Business Account and matches the intent address. It throws a dedicated error when the proposal cannot be resumed:

* `SigningProposalNotPendingError` when the proposal already left the pending state (executed, expired, or vetoed).
* `SigningProposalApprovalsRequiredError` when its approvals are missing or unmet.
* `SigningProposalIntentUnavailableError` when it has no valid stored intent.
* `SigningProposalWalletMismatchError` when the wallet belongs to another business account; pass the `walletAccount` the intent names.
* `SigningProposalNetworkMismatchError` when the wallet's active network differs from the network the transaction intent was approved for, for a Solana transaction or an EVM transaction with a refreshable nonce.

The proposal is consumed when the resume succeeds. The intent stored on the proposal binds the signature to the operation, so resume the stored proposal rather than rebuilding the request.

> [!WARNING]
>
> Review the proposal's stored operation before approving it, and test the complete initiation, approval, resume, and broadcast flow in a non-production environment before enabling a quorum rule in production.

Changes to approver roles, thresholds, or deadlines apply to subsequent evaluations. Start a new signing request after changing a quorum rule so the request is evaluated against the current policy.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.