Skip to main content
In Railnet smart contracts, an Advanced Strategy is implemented as a Specialized Vehicle. The Policy Engine is a Zodiac Roles Modifier v2 contract enabled as a module on the Strategy’s Safe wallet.
This is the operator guide for teams running protocol actions on a Railnet Advanced Strategy — the on-chain container that holds the capital and enforces the mandate you were given. It assumes you already know how to craft a transaction for your target protocol (supply, swap, mint, whatever) and that you already have a production signer. What you don’t yet know is the one extra step that routes your calldata through the Strategy’s Policy Engine so your mandate is enforced for you on-chain. Good news for the PM: your current execution engine keeps doing everything it already does. You add one outer encode step and change the to address. That’s the whole integration. Good news for the dev: it’s one ABI fragment, one function call, six args.

TL;DR

inner   = encode(protocol.someAction, args)          # you already know how
wrapped = encode(RolesModifier.execTransactionWithRole,
                 [innerTo, 0, inner, 0, ROLE_KEY, true])
tx      = { to: POLICY_ENGINE_ADDRESS, data: wrapped, value: 0 }
signer.signAndBroadcast(tx)                          # your existing infra
Four lines. The first and last already exist in your codebase. Line 2 is the integration.

Mental model

  • The Advanced Strategy is a Safe. It holds the capital. Shortened to “the Strategy” for the rest of this guide.
  • The Policy Engine is a Zodiac Roles Modifier v2 contract, enabled as a module on the Strategy. It is a separate address from the Strategy — the administrator gives you both.
  • Your member signer (EOA, hardware wallet, Fireblocks vault, Safe, KMS key, anything) is registered against a role key on the Policy Engine. That role defines exactly which functions, on which targets, with which argument shapes, you are allowed to push through.
On-chain call flow: From the protocol’s perspective msg.sender == STRATEGY_ADDRESS. No allowlist update, no integration change, nothing about your signer is visible to the target contract.

What the administrator gives you

NameTypeWhat it is
STRATEGY_ADDRESSaddressThe Safe that holds the capital and executes the protocol call
POLICY_ENGINE_ADDRESSaddressThe Roles Modifier v2 contract enabled as a module on the Strategy
ROLE_KEYbytes32Identifier for your member role on the Policy Engine
These are independent values. There is no deterministic derivation between STRATEGY_ADDRESS and POLICY_ENGINE_ADDRESS — ask the admin for both.

The role key

Two encodings are in the wild: Numeric index — a uint left-padded to 32 bytes. Simplest single-role deployments use this.
0x0000000000000000000000000000000000000000000000000000000000000001
ASCII label packed as bytes32 — an admin-chosen human-readable string, left-aligned and right-zero-padded to 32 bytes. Most production deployments use this because it’s greppable in explorer traces. Example — "aave_usdc":
0x616176655f757364630000000000000000000000000000000000000000000000
At runtime the value is opaque to you. The admin tells you the literal bytes32; you pass it through. If it’s wrong, the Policy Engine reverts with NoMembership.

The only ABI you need

This is the one contract surface you must paste into your ABI registry. Everything else about the Policy Engine is invisible to the execution path.
{
  "name": "execTransactionWithRole",
  "type": "function",
  "stateMutability": "nonpayable",
  "inputs": [
    { "name": "to",           "type": "address" },
    { "name": "value",        "type": "uint256" },
    { "name": "data",         "type": "bytes"   },
    { "name": "operation",    "type": "uint8"   },
    { "name": "roleKey",      "type": "bytes32" },
    { "name": "shouldRevert", "type": "bool"    }
  ],
  "outputs": [{ "name": "success", "type": "bool" }]
}
Argument by argument:
  • to — the protocol target (Aave Pool, Uniswap Router, USDC token, …), not the Strategy.
  • value — ETH to forward. Almost always 0.
  • data — your already-crafted inner calldata, untouched.
  • operationalways 0 (CALL). Never 1 (DELEGATECALL); that path is reserved for internal tooling and has no valid use from an operator member.
  • roleKey — the bytes32 from the section above.
  • shouldRevert — set to true. Makes inner failures revert the whole transaction instead of silently returning false, so your ops pipeline sees real errors.

The flow in pseudocode

Language-agnostic. Steps 1, 3, and 4 are what you’re already doing for direct protocol calls today; step 2 is the entire integration.
1

Craft your inner protocol call the way you already do

inner_to, inner_data, inner_value = craft_your_protocol_tx(...)
2

Wrap it in execTransactionWithRole

This is the one new step.
wrapped_data = abi_encode(
    ROLES_MODIFIER_EXEC_ABI,
    "execTransactionWithRole",
    [inner_to, inner_value, inner_data, 0, ROLE_KEY, True],
)
3

Build an ordinary transaction with the Policy Engine as the target

tx = {
    "to":    POLICY_ENGINE_ADDRESS,
    "data":  wrapped_data,
    "value": 0,
    # nonce / gas / chainId filled in by your existing signer pipeline
}
4

Hand it to whatever signer you already use

signed  = signer.sign(tx)
tx_hash = rpc.send_raw_transaction(signed)
receipt = rpc.wait_for_receipt(tx_hash)
That’s the contract: encode one extra layer, change the to address, everything else stays.

Reference implementation

Generic version using TypeScript and viem. Replace the two marked variables with whatever your existing protocol-crafting code produces.
import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { mainnet } from "viem/chains" // your chain of choice

const ROLES_ABI = [
  { name: "execTransactionWithRole", type: "function", stateMutability: "nonpayable",
    inputs: [
      { name: "to",           type: "address" },
      { name: "value",        type: "uint256" },
      { name: "data",         type: "bytes"   },
      { name: "operation",    type: "uint8"   },
      { name: "roleKey",      type: "bytes32" },
      { name: "shouldRevert", type: "bool"    },
    ],
    outputs: [{ name: "success", type: "bool" }] },
] as const

const wallet = createWalletClient({
  account:   privateKeyToAccount(MEMBER_PRIVATE_KEY),
  chain:     mainnet,
  transport: http(RPC_URL),
})

// 1. You already produced these
const INNER_TARGET: `0x${string}` = /* your protocol contract */
const INNER_DATA:   `0x${string}` = /* your protocol calldata  */

// 2. Push it through the Policy Engine
const txHash = await wallet.writeContract({
  address:      POLICY_ENGINE_ADDRESS,
  abi:          ROLES_ABI,
  functionName: "execTransactionWithRole",
  args: [INNER_TARGET, 0n, INNER_DATA, 0, ROLE_KEY, true],
})
Swap privateKeyToAccount for your signer’s viem-compatible account object — Fireblocks, Turnkey, AWS KMS, Ledger Connect, Dynamic, Privy, and most others ship one. The rest of the snippet is identical regardless of backend.

Any signer, same bytes

The Policy Engine does not care how a transaction was signed, only that the from address is a registered member of the role. Anything that can sign a standard EIP-1559 contract call to POLICY_ENGINE_ADDRESS works out of the box.
As shown in the reference implementation above. Sign and broadcast directly with a private key using viem, ethers, or any web3 library.
User approves on device. The outer transaction is a plain contract call, so the device shows to = Policy Engine, function = execTransactionWithRole.
Create a contract-call transaction in the provider’s SDK with to = POLICY_ENGINE_ADDRESS, data = wrapped_data. No custom integration on the provider side.
Sign the raw transaction bytes exactly as you would any other contract call. The Policy Engine sees a standard from address — it does not inspect the signing backend.
Covered in the next section because it has two extra steps.
Every option above produces the same on-chain call. Pick whichever fits your existing ops posture and you are done.

When the member signer is a Safe

Some teams register a dedicated Safe as the Policy Engine member so signing authority is already multi-party before any transaction touches the Strategy. The flow adds two steps around what you already built above.
  1. Build wrapped_data exactly as in step 2 of the pseudocode flow (targeting the Policy Engine).
  2. Wrap it a second time as a Safe transaction: a SafeTx with to = POLICY_ENGINE_ADDRESS, data = wrapped_data, value = 0, operation = 0.
  3. Collect owner signatures on the SafeTx hash via your existing Safe flow — Safe Transaction Service, in-house queue, whatever you already run.
  4. Execute the SafeTx. The on-chain path becomes:
Member Safe --> Policy Engine --> Strategy Safe --> Protocol
Minimal wrap step with @safe-global/protocol-kit (the rest — proposal, confirmation collection, execution — is your existing Safe infra):
import Safe from "@safe-global/protocol-kit"

const memberSafe = await Safe.init({
  provider:    RPC_URL,
  signer:      OWNER_KEY,
  safeAddress: MEMBER_SAFE_ADDRESS,
})

const safeTx = await memberSafe.createTransaction({
  transactions: [{
    to:        POLICY_ENGINE_ADDRESS,
    value:     "0",
    data:      wrappedData,   // from step 2 of the pseudocode flow
    operation: 0,
  }],
})
// ...then sign, propose, and execute via your existing Safe flow
Gas is paid by whichever owner executes the SafeTx, not the proposer.

Policy engine

Understand how the on-chain policy engine governs what operators can do.

Advanced Strategies

Overview of Specialized Vehicles — custody, policy, and accounting.

Risk management

Risk frameworks and guardrails for strategy operations.