npm package: @31third/sdk
Description: 31Third SDK for Safe automation deployment and policy-guarded execution
Latest version: 0.3.3

# @31third/sdk

SDK for deploying `ExecutorModule` on Safe and executing policy-guarded rebalancings. It helps you create the Safe deployment proposal, attach on-chain policies, calculate a rebalance, and execute it with a dedicated executor wallet.

Table of contents:
- [Concept](#concept)
- [Install](#install)
- [Quick start](#quick-start)
- [Step 1: Create and fund a Safe](#step-1-create-and-fund-a-safe)
- [Step 2: Choose owner and executor](#step-2-choose-owner-and-executor)
- [Step 3: Deploy ExecutorModule + policies](#step-3-deploy-executormodule--policies)
- [Step 4: Calculate a rebalancing](#step-4-calculate-a-rebalancing)
- [Step 5: Execute the rebalancing](#step-5-execute-the-rebalancing)
- [Rebalancing payload parameters](#rebalancing-payload-parameters)
- [Chain overrides](#chain-overrides)
- [View helpers](#view-helpers)
- [Policy reference](#policy-reference)
- [Execution integration guide](#execution-integration-guide)
- [Current status](#current-status)

## Concept

`ExecutorModule` lets a Safe keep custody of assets while delegating trading to a dedicated executor wallet. Every execution is checked against on-chain policies before the Safe trade is allowed to proceed.

Important rules:
- The module `owner` and `executor` must be different addresses.
- By default, SDK deployment sets the module owner to the Safe itself.
- You can choose a different owner if you deploy through a flow that supports it, but `owner != executor` must still hold.
- The executor wallet is a hot wallet and should never be used to create or approve the Safe deployment transaction.

Setup overview:
1. Create and fund a Safe.
2. Choose a dedicated executor wallet.
3. Deploy `ExecutorModule` plus policies to the Safe.
4. Use the executor wallet to calculate and execute rebalances.

Security notes:
- Use a dedicated hot wallet for execution with limited funds.
- Keep policies strict (asset universe, slippage, allocation) before enabling autonomous execution.
- Treat the executor wallet as production infrastructure; rotate keys and monitor activity.

## Install

```bash
npm install @31third/sdk
```

Deployment note: the Safe deployment proposal must be created by a Safe signer, not by the executor wallet.

## Quick start

The recommended flow is:
1. Create and fund a Safe.
2. Choose distinct `owner` and `executor` addresses.
3. Deploy `ExecutorModule` and policies.
4. Calculate a rebalance.
5. Execute it with the executor wallet.

## Step 1: Create and fund a Safe

Create a Safe at https://app.safe.global/ and fund it with the assets you want the strategy to manage.

## Step 2: Choose owner and executor

- `owner`: address allowed to manage module settings such as executor, cooldown, and policies.
- `executor`: address allowed to call `execute`.
- These addresses must be different.
- In the default SDK deployment flow, the Safe is used as the module owner.

Recommended setup:
- `owner = SAFE_ADDRESS`
- `executor = dedicated hot wallet`

## Step 3: Deploy ExecutorModule + policies

You can deploy in two ways:

Recommended:
- Use https://app.31third.com/safe-policy-deployer to configure policies and create the Safe transaction.

SDK flow:
- Use `proposeDeployModuleBatchTx` when you want to generate and submit the Safe proposal programmatically.
- Use `buildDeploySafePolicyStackTx` when you want an already-deployed `SafePolicyStackFactory` to build the factory deployment transaction for a connected wallet to sign and send directly. This helper does not submit anything to the Safe transaction service. After that transaction executes, call `proposeEnableModuleTx` with the deployed module address.

```ts
import { proposeDeployModuleBatchTx } from '@31third/sdk';
import { JsonRpcProvider, Wallet } from 'ethers';

const provider = new JsonRpcProvider(process.env.RPC_URL!);
const safeSigner = new Wallet(process.env.SAFE_SIGNER_PK!, provider);
const safeAddress = process.env.SAFE_ADDRESS!;
const executor = process.env.EXECUTOR_ADDRESS!;
const priceOracle = process.env.PRICE_ORACLE!;

await proposeDeployModuleBatchTx({
  automationId: 'uuid-or-id',
  safeAddress,
  signer: safeSigner, // Safe signer, not the executor
  chainId: 8453,
  executor,
  batchTrade: process.env.BATCH_TRADE_ADDRESS!,
  policyConfig: {
    cooldown: 3600,
    assetUniverse: {
      assetUniverseTokens: ['0xToken1', '0xToken2'],
    },
    slippage: {
      priceOracle,
      maxSlippageBps: 50,
    },
    staticAllocation: {
      priceOracle,
      targetTokens: ['0xToken1', '0xToken2'],
      targetBps: [5000, 5000],
      driftThresholdBps: 50,
      toleranceThresholdBps: 50,
    },
  },
});
```

After the Safe transaction is approved and executed, record the deployed module address. You will need it for execution.

Factory flow:

```ts
import {
  buildDeploySafePolicyStackTx,
  proposeEnableModuleTx,
} from '@31third/sdk';
import { JsonRpcProvider, Wallet } from 'ethers';

const provider = new JsonRpcProvider(process.env.RPC_URL!);
const connectedWallet = new Wallet(process.env.CONNECTED_WALLET_PK!, provider);
const safeSigner = new Wallet(process.env.SAFE_SIGNER_PK!, provider);

const { safeTx, moduleAddress } = await buildDeploySafePolicyStackTx({
  safeAddress: process.env.SAFE_ADDRESS!,
  factoryAddress: process.env.SAFE_POLICY_STACK_FACTORY!, // Optional, defaults to 0xC13C487819b0c656c49fD7Cf5A9f23E77FF42aF8
  executor: process.env.EXECUTOR_ADDRESS!,
  batchTrade: process.env.BATCH_TRADE_ADDRESS!,
  policyConfig: {
    cooldown: 3600,
    assetUniverse: {
      assetUniverseTokens: ['0xToken1', '0xToken2'],
    },
    slippage: {
      priceOracle: process.env.PRICE_ORACLE!, // Optional, defaults to 0x37C6D6729FF637Ed9bF493c897794B256AFee004
      maxSlippageBps: 50,
    },
    staticAllocation: {
      priceOracle: process.env.PRICE_ORACLE!, // Optional, defaults to 0x37C6D6729FF637Ed9bF493c897794B256AFee004
      targetTokens: ['0xToken1', '0xToken2'],
      targetBps: [5000, 5000],
      driftThresholdBps: 50,
      toleranceThresholdBps: 50,
    },
  },
});

const txResponse = await connectedWallet.sendTransaction({
  to: safeTx.to,
  data: safeTx.data,
  value: safeTx.value,
});
const receipt = await txResponse.wait();

// `moduleAddress` is predicted up front. You can also read the deployed address
// from the SafePolicyStackFactory StackDeployed event in `receipt` if needed.
await proposeEnableModuleTx({
  safeAddress: process.env.SAFE_ADDRESS!,
  moduleAddress,
  signer: safeSigner,
  chainId: 8453,
});
```

Factory note: the SDK now sends CREATE2 deployment bundles to `SafePolicyStackFactory`,
matching the current protocol contract. The helper also predicts the deployed
addresses up front from the factory address, salts, and creation code.
Defaults:
- `priceOracle`: `0x37C6D6729FF637Ed9bF493c897794B256AFee004`
- `safePolicyStackFactory`: `0xC13C487819b0c656c49fD7Cf5A9f23E77FF42aF8`
- If you omit these fields in the factory flow, the SDK uses the defaults above.
- You can override the defaults explicitly by passing `factoryAddress` and/or policy `priceOracle` values.

After the factory Safe transaction executes, you can notify the backend in a best-effort way:

```ts
import { trackFactoryDeployment } from '@31third/sdk';

await trackFactoryDeployment({
  automationId: 'uuid-or-id',
  safeAddress: process.env.SAFE_ADDRESS!,
  chainId: 8453,
  executor: process.env.EXECUTOR_ADDRESS!,
  batchTrade: process.env.BATCH_TRADE_ADDRESS!,
  factoryAddress: process.env.SAFE_POLICY_STACK_FACTORY!, // Optional, defaults to 0xC13C487819b0c656c49fD7Cf5A9f23E77FF42aF8
  transactionHash: process.env.EXECUTED_SAFE_TX_HASH!,
  provider,
});
```

`trackFactoryDeployment` resolves the deployed addresses from the
`SafePolicyStackFactory` `StackDeployed` event and sends them to the backend.
It is best-effort and returns `undefined` if receipt resolution, event parsing,
or backend notification fails.

## Step 4: Calculate a rebalancing

Use the 31Third API to calculate the rebalance for the Safe portfolio.

```ts
import { calculateRebalancing } from '@31third/sdk';

const rebalancing = await calculateRebalancing({
  apiBaseUrl: 'https://api.31third.com/1.3',
  apiKey: process.env.API_KEY!,
  chainId: 8453,
  payload: {
    wallet: process.env.SAFE_ADDRESS!,
    signer: process.env.EXECUTOR_ADDRESS!,
    simulationTxOrigin: process.env.EXECUTOR_ADDRESS!,
    baseEntries: [],
    targetEntries: [],
    maxSlippage: 0.01,
    maxPriceImpact: 0.05,
    minTradeValue: 0.01,
    skipBalanceValidation: false,
  },
});
```

## Step 5: Execute the rebalancing

Execute the returned `txData` through the deployed module with the dedicated executor wallet.

```ts
import { executeRebalancing } from '@31third/sdk';
import { JsonRpcProvider, Wallet } from 'ethers';

const executor = new Wallet(
  process.env.EXECUTOR_PK!,
  new JsonRpcProvider(process.env.RPC_URL!),
);

const tx = await executeRebalancing({
  signer: executor,
  executorModule: process.env.EXECUTOR_MODULE!,
  rebalancing,
});

await tx.wait();
```

## Rebalancing payload parameters

These are the fields you can set in `calculateRebalancing({ payload })`:

- `wallet`: Safe address the rebalancing is calculated for.
- `signer`: address that will sign and execute on-chain. In the single-wallet executor flow, this should be the executor address.
- `simulationTxOrigin`: optional override for simulation. In most cases this should match the executor address.
- `baseEntries`: list of `{ tokenAddress, amount }` for the assets to sell. `amount` is in token base units.
- `targetEntries`: list of `{ tokenAddress, allocation }` where `allocation` is a fraction such as `0.2` for 20%.
- `maxDeviationFromTarget`: maximum allowed deviation from the target allocation before the API rejects the rebalance.
- `maxSlippage`: maximum slippage used when calculating trades.
- `maxPriceImpact`: maximum total price impact allowed for the basket trade.
- `minTradeValue`: minimum trade value in USD.
- `batchTrade`: whether batch trade mode is enabled.
- `revertOnError`: whether the full batch should revert if one trade fails.
- `skipBalanceValidation`: skip Safe balance checks.
- `failOnMissingPricePair`: fail if a required price pair is missing.
- `async`: calculate asynchronously.

Token address note: use `ETH` or `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee` for native ETH.

## Chain overrides

You can override chain constants by passing `chainOverrides` to deployment helpers:

```ts
import { proposeDeployModuleBatchTx } from '@31third/sdk';

await proposeDeployModuleBatchTx({
  /* ... */
  chainOverrides: {
    safeTxServiceUrls: {
      8453: 'https://safe-transaction-base.safe.global',
    },
    multisendAddresses: {
      8453: '0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526',
    },
    create2FactoryAddresses: {
      8453: '0xce0042B868300000d44A59004Da54A005ffdcf9f',
    },
    safeAppPrefixes: {
      8453: 'base',
    },
    safeTxGasMultiplier: 1.2,
    forceSafeTxGasZero: true,
  },
});
```

Fields:
- `safeTxServiceUrls`: Safe Transaction Service API per chain id.
- `multisendAddresses`: MultiSend contract address per chain id.
- `create2FactoryAddresses`: CREATE2 factory per chain id.
- `safeAppPrefixes`: Safe app URL prefix per chain id.
- `safeTxGasMultiplier`: optional multiplier applied to Safe Tx Service gas estimation (default `1.2`).
- `forceSafeTxGasZero`: force `safeTxGas` to `0` and skip Safe Tx Service gas estimation (default `false`).

## View helpers

The SDK now includes read helpers for `ExecutorModule` and policy contracts.

```ts
import {
  getExecutorModuleState,
  getPoliciesWithTypes,
  areTokensAllowed,
} from '@31third/sdk';
import { JsonRpcProvider } from 'ethers';

const provider = new JsonRpcProvider(process.env.RPC_URL!);

const state = await getExecutorModuleState(process.env.EXECUTOR_MODULE!, provider);
const policies = await getPoliciesWithTypes(process.env.EXECUTOR_MODULE!, provider);
const allowed = await areTokensAllowed(process.env.ASSET_UNIVERSE_POLICY!, [
  '0x4200000000000000000000000000000000000006',
  '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
], provider);
```

### ExecutorModule

| Function | Returns |
|---|---|
| `getExecutorModuleState(executorModule, runner)` | `{ owner, executor, safe, batchTrade, cooldown, lastExecution }` (`cooldown`, `lastExecution` are `bigint`) |
| `getPolicies(executorModule, runner)` | `string[]` (policy addresses) |
| `getPoliciesWithTypes(executorModule, runner)` | `{ policy: string, policyType: string }[]` |
| `checkPoliciesVerbose(executorModule, trades, config, runner)` | `{ ok: boolean, failedPolicy: string, reason: string }` |

### AssetUniversePolicy

| Function | Returns |
|---|---|
| `getAssetUniverseTokens(assetUniversePolicy, runner)` | `string[]` (allowed token addresses) |
| `isTokenAllowed(assetUniversePolicy, token, runner)` | `boolean` |
| `areTokensAllowed(assetUniversePolicy, tokens, runner)` | `boolean[]` (same order as input `tokens`) |

### StaticAllocationPolicy

| Function | Returns |
|---|---|
| `getStaticAllocationTargets(staticAllocationPolicy, runner)` | `{ token: string, bps: bigint }[]` |
| `getStaticAllocationConfig(staticAllocationPolicy, runner)` | `{ assetUniverse: string, priceOracle: string, driftThresholdBps: bigint, toleranceThresholdBps: bigint }` |

### SlippagePolicy

| Function | Returns |
|---|---|
| `getSlippageConfig(slippagePolicy, runner)` | `{ priceOracle: string, maxSlippageBps: bigint, owner: string }` |

### Policy reason decoding

| Function | Returns |
|---|---|
| `decodePolicyFailure(policyAddress, reasonData, runner)` | `string` (decoded reason, or empty string if unavailable) |

## Policy reference

This SDK can deploy these policies and attach them to the ExecutorModule. All policies run on-chain before execution and can veto trades.

### AssetUniversePolicy

Purpose: restrict trades to a fixed list of allowed tokens.

How it works:
- On every trade, checks that `trade.from` and `trade.to` are in the allowed token set.
- Any token not explicitly allowed fails the policy.

Owner controls:
- `allowToken(token)` adds a token to the universe.
- `disallowToken(token)` removes a token from the universe.

Failure modes (custom errors):
- `AssetUniverseFromTokenNotAllowed(token)`
- `AssetUniverseToTokenNotAllowed(token)`

Use cases:
- Prevent AI or external executors from trading into unknown assets.
- Enforce allowlists per portfolio or strategy.

### StaticAllocationPolicy

Purpose: enforce a target portfolio allocation (in bps) and only allow rebalances when a deviation threshold is met.

Inputs:
- `targetTokens` and `targetBps` (must sum to 10_000).
- `assetUniverse` (optional, if provided it defines the full supported token set).
- `priceOracle` for price feeds.
- `driftThresholdBps`: minimum deviation from target that triggers a rebalance.
- `toleranceThresholdBps`: maximum deviation allowed after executing the proposed trades.

How it works:
1) Loads current balances for all supported tokens.
2) Computes current USD weights via price oracle.
3) If no token deviates by at least `driftThresholdBps`, it fails with `StaticAllocationNoRebalanceTrigger`.
4) Simulates the proposed trades using `minToReceiveBeforeFees` and updates balances.
5) Computes predicted USD weights. If any token is outside `toleranceThresholdBps`, it fails.

Failure modes (custom errors):
- `StaticAllocationFeedMissing(token)`
- `StaticAllocationZeroPortfolioValue()`
- `StaticAllocationNoRebalanceTrigger()`
- `StaticAllocationTokenNotSupported(token)`
- `StaticAllocationInsufficientBalance(token, required, available)`
- `StaticAllocationZeroPredictedValue()`
- `StaticAllocationOutsideTolerance()`

Notes:
- Requires all tokens to be supported by the configured price oracle.
- `targetTokens` must be in the asset universe when `assetUniverse` is provided.
- Use `assetUniverse` to include on-ramp tokens (e.g., USDC/USDT) even if the target allocation is only WBTC/WETH; the universe defines which tokens the strategy can trade through.

### SlippagePolicy

Purpose: ensure `minToReceiveBeforeFees` isn’t below a maximum slippage bound.

Inputs:
- `priceOracle` for price feeds.
- `maxSlippageBps` (<= 10_000).

How it works:
- For each trade, gets USD price feeds for `from` and `to`.
- Computes expected output amount and enforces `minToReceiveBeforeFees >= expected * (1 - maxSlippageBps/10_000)`.

Failure modes (custom errors):
- `SlippageFeedMissing(tradeIndex, token)`
- `SlippageMinToReceiveTooLow(tradeIndex, minToReceive, minAllowed)`

Notes:
- Requires feeds for both `from` and `to` tokens.
- Uses token decimals and feed decimals to normalize expected output.

## Execution integration guide

Use this when implementing an executor service, bot, or agent:

Inputs:
- `SAFE_ADDRESS`: Safe that holds assets.
- `EXECUTOR_MODULE`: Deployed module address.
- `RPC_URL`: Chain RPC.
- `API_KEY`: 31Third API key for rebalancing.
- `CHAIN_ID`: 1 / 137 / 8453 / 42161.
- `EXECUTOR_PK`: Private key of the executor wallet.

Steps:
1) Fetch current Safe token balances and use those amounts for `baseEntries` in `calculateRebalancing`. It is not necessary to include all balances; include only the tokens/amounts you want to rebalance. All selected tokens must be in the defined asset universe.
2) Call `calculateRebalancing` with `rebalancing/wallet` payload.
3) Call `executeRebalancing` or decode `txData` and call `checkPoliciesVerbose` yourself before execution.
4) If policy checks pass, execute through the module with the executor wallet.
5) Record tx hash and any policy failure reasons for auditing.

Expected outputs:
- Rebalancing response (txData + requiredAllowances).
- Execution tx hash (or a policy failure reason).

## Current status

- Ethereum and Base are supported right now.
- Configure the correct chain-specific `priceOracle` address when deploying policies.
- Supported Ethereum Mainnet tokens:

| Token Symbol | Token Address |
|---|---|
| wstETH | `0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0` |
| WBTC | `0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599` |
| AAVE | `0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9` |
| LINK | `0x514910771AF9Ca656af840dff83E8264EcF986CA` |
| UNI | `0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984` |
| WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` |
| WSOL | `0xD31a59c85aE9D8edEFeC411D448f90841571b89c` |
| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` |
| USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` |
| EURC | `0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c` |

- Supported Base Mainnet tokens:

| Token Symbol | Token Address |
|---|---|
| WBTC | `0x0555e30da8f98308edb960aa94c0db47230d2b9c` |
| cbETH | `0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22` |
| SOL | `0x311935cd80b76769bf2ecc9d8ab7635b2139cf82` |
| DAI | `0x50c5725949a6f0c72e6c4a641f24049a917db0cb` |
| PEPE | `0x52b492a33e447cdb854c7fc19f1e57e8bfa1777d` |
| EURC | `0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42` |
| AAVE | `0x63706e401c06ac8513145b7687a14804d17f814b` |
| cbDOGE | `0xcbd06e5a2b0c65597161de254aa074e489deb510` |
| ZRO | `0x6985884c4392d348587b19cb9eaaf157f13271cd` |
| USDC | `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` |
| LINK | `0x88fb150bdc53a65fe94dea0c9ba0a6daf8c6e196` |
| AERO | `0x940181a94a35a4569e4529a3cdfb74e38fd98631` |
| COMP | `0x9e1028f5f1d5ede59748ffcee5532509976840e0` |
| YFI | `0x9eaf8c1e34f05a589eda6bafdf391cf6ad3cb239` |
| OP | `0xafcc6ae807187a31e84138f3860d4ce27973e01b` |
| MORPHO | `0xbaa5cc21fd487b8fcc2f632f3f4e8d37262a0842` |
| cbBTC | `0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf` |
| RDNT | `0xd722e55c1d9d9fa0021a5215cbb904b92b3dc5d4` |
| LBTC | `0xecac9c5f704e954931349da37f60e39f515c11c1` |
| WETH | `0x4200000000000000000000000000000000000006` |
| aBascbBTC | `0xbdb9300b7cde636d9cd4aff00f6f009ffbbc8ee6` |
| aBasUSDC | `0x4e65fe4dba92790696d040ac24aa414708f5c0ab` |
| aBasWETH | `0xd4a0e0b9149bcee3c920d2e00b5de09138fd8bb7` |