@31third/sdk
v0.3.3
Published
31Third SDK for Safe automation deployment and policy-guarded execution
Readme
@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
- Install
- Quick start
- Step 1: Create and fund a Safe
- Step 2: Choose owner and executor
- Step 3: Deploy ExecutorModule + policies
- Step 4: Calculate a rebalancing
- Step 5: Execute the rebalancing
- Rebalancing payload parameters
- Chain overrides
- View helpers
- Policy reference
- Execution integration guide
- 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
ownerandexecutormust 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 != executormust still hold. - The executor wallet is a hot wallet and should never be used to create or approve the Safe deployment transaction.
Setup overview:
- Create and fund a Safe.
- Choose a dedicated executor wallet.
- Deploy
ExecutorModuleplus policies to the Safe. - 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
npm install @31third/sdkDeployment note: the Safe deployment proposal must be created by a Safe signer, not by the executor wallet.
Quick start
The recommended flow is:
- Create and fund a Safe.
- Choose distinct
ownerandexecutoraddresses. - Deploy
ExecutorModuleand policies. - Calculate a rebalance.
- 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 callexecute.- These addresses must be different.
- In the default SDK deployment flow, the Safe is used as the module owner.
Recommended setup:
owner = SAFE_ADDRESSexecutor = 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
proposeDeployModuleBatchTxwhen you want to generate and submit the Safe proposal programmatically. - Use
buildDeploySafePolicyStackTxwhen you want an already-deployedSafePolicyStackFactoryto 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, callproposeEnableModuleTxwith the deployed module address.
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:
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:0x37C6D6729FF637Ed9bF493c897794B256AFee004safePolicyStackFactory:0xC13C487819b0c656c49fD7Cf5A9f23E77FF42aF8- If you omit these fields in the factory flow, the SDK uses the defaults above.
- You can override the defaults explicitly by passing
factoryAddressand/or policypriceOraclevalues.
After the factory Safe transaction executes, you can notify the backend in a best-effort way:
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.
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.
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.amountis in token base units.targetEntries: list of{ tokenAddress, allocation }whereallocationis a fraction such as0.2for 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:
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 (default1.2).forceSafeTxGasZero: forcesafeTxGasto0and skip Safe Tx Service gas estimation (defaultfalse).
View helpers
The SDK now includes read helpers for ExecutorModule and policy contracts.
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.fromandtrade.toare 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:
targetTokensandtargetBps(must sum to 10_000).assetUniverse(optional, if provided it defines the full supported token set).priceOraclefor price feeds.driftThresholdBps: minimum deviation from target that triggers a rebalance.toleranceThresholdBps: maximum deviation allowed after executing the proposed trades.
How it works:
- Loads current balances for all supported tokens.
- Computes current USD weights via price oracle.
- If no token deviates by at least
driftThresholdBps, it fails withStaticAllocationNoRebalanceTrigger. - Simulates the proposed trades using
minToReceiveBeforeFeesand updates balances. - 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.
targetTokensmust be in the asset universe whenassetUniverseis provided.- Use
assetUniverseto 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:
priceOraclefor price feeds.maxSlippageBps(<= 10_000).
How it works:
- For each trade, gets USD price feeds for
fromandto. - 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
fromandtotokens. - 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:
- Fetch current Safe token balances and use those amounts for
baseEntriesincalculateRebalancing. 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. - Call
calculateRebalancingwithrebalancing/walletpayload. - Call
executeRebalancingor decodetxDataand callcheckPoliciesVerboseyourself before execution. - If policy checks pass, execute through the module with the executor wallet.
- 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
priceOracleaddress 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 |
