Documentation

Deposit & Withdraw

Moving funds through a Conduit is two calls: create() to enter or exit, and process() to advance a query that did not settle immediately. This page covers both, plus reading a holder’s position.

The panel below is the same component the app uses on every /conduit/<address> page, pointed at a live Conduit. Everything it does is plain viem against the vendored Conduit ABI.

Pre-requisites:

  • A wallet on Base holding the Conduit’s underlying asset, and ETH for gas
  • A Conduit that is open — ready() must be true, see Deploy a Conduit

Steps:

1
Read a position
2
Deposit
3
Redeem

Try it

1. Read a position

A holder’s position is their conduit-share balance plus what those shares convert to. convert() skips transactional fees but does include accrued management and performance dilution, which makes it the right number for “what is this worth” and the wrong one for “what will I receive”.

const shares = await client.readContract({
	address: conduit,
	abi: conduitAbi,
	functionName: 'balanceOf',
	args: [account],
});

// The second argument is sharesToAssets.
const worth = await client.readContract({
	address: conduit,
	abi: conduitAbi,
	functionName: 'convert',
	args: [{ asset: conduit, value: shares }, true],
});

For what a deposit or redeem would actually return, use estimate() — it applies the Conduit’s fees:

const [DEPOSIT, REDEEM] = [0, 1];
const OUTPUT = 1;

const estimated = await client.readContract({
	address: conduit,
	abi: conduitAbi,
	functionName: 'estimate',
	args: [{ asset: assetAddress, value: amount }, DEPOSIT, OUTPUT],
});

2. Deposit

A deposit is a STEAM query created through the Conduit. Two details decide whether it works, and both revert if you get them wrong.

output.asset must be the Vehicle, not the Conduit. Conduit._create forwards your query to vehicle.create(query) verbatim, and BaseVehicle._validateOutput requires output.asset == address(vehicle) on a deposit. Passing the conduit address — or a zero address — reverts 0x3fbec7fa. Read it with getVehicle().

query.salt is bound to the caller. create() reverts unless query.salt == keccak256(abi.encode(msg.sender, sourceSalt)). That binding is what stops anyone else occupying your queryId. Keep the sourceSalt — nothing on-chain lets you recover it, and process() needs it to rebuild the query.

const vehicle = await client.readContract({
	address: conduit, abi: conduitAbi, functionName: 'getVehicle',
});

// Step 1 — approve the Conduit to pull the asset.
await walletClient.writeContract({
	address: assetAddress, abi: erc20Abi, functionName: 'approve',
	args: [conduit, amount],
});

// Step 2 — create the query.
const sourceSalt = '0x...'; // 32 random bytes; persist it
const query = {
	owner: conduit,
	receiver: conduit,
	input: { asset: assetAddress, value: amount },
	output: { asset: vehicle, value: 0n },   // 0 disables the output floor
	mode: 0,                                  // DEPOSIT
	salt: keccak256(
		encodeAbiParameters([{ type: 'address' }, { type: 'bytes32' }], [account, sourceSalt])
	),
	data: '0x',
};

await walletClient.writeContract({
	address: conduit, abi: conduitAbi, functionName: 'create',
	args: [query, account, sourceSalt],
});

3. Redeem

The mirror image, with one asymmetry worth knowing.

No approval is needed. The redeem query’s input asset is vehicle shares, so _create burns your cShares with an internal _transfer that never reaches transferFrom. An approve here is a wasted signature and a wasted transaction.

The salt is passed raw. createRedeemFromConduitShares derives query.salt from (msg.sender, sourceSalt) inside the contract, so — unlike deposit — you do not hash it first.

await walletClient.writeContract({
	address: conduit,
	abi: conduitAbi,
	functionName: 'createRedeemFromConduitShares',
	args: [
		shares,
		{ asset: assetAddress, value: 0n },  // output floor, 0 disables
		sourceSalt,                          // raw, NOT hashed
		account,
	],
});

Settling an async query

On a synchronous Vehicle the query settles inside your transaction and you are done. On an asynchronous one it stays in PROCESSING until someone calls process() with the exact query struct that created it — which is why the sourceSalt has to be persisted at creation time.

await walletClient.writeContract({
	address: conduit, abi: conduitAbi, functionName: 'process',
	args: [query],   // byte-identical to the one passed to create()
});

Who runs that keeper is not currently documented. The CONDUIT_PROCESS role exists, but no published keeper job covers conduit queries. Until that is settled, treat user-driven process() as a path you have to support yourself.

On the TypeScript SDK

There is a @railnetorg/railnet-sdk package. This app does not use it, and the code on this page is native viem instead. The reason is concrete: the SDK targets a different Railnet deployment, whose query structs are Asset[] where these contracts take a single Asset. That changes the selector on every call carrying one, verified in both directions on-chain:

callSDK shapethis deployment
convert0x31d6c8ab0x500af719
create0x0f2925940x50bb3af4
createRedeemFromConduitShares0x340434650x4c717f8d
estimate0xc1bad5eb0xd34eca33

Beyond the ABI split, three SDK defects would still block this flow once it is resolved: prepareDepositConduit sends an empty output (see the deposit warning above), redeemConduit sends the unnecessary approval, and getConduitPosition skips its convert() call when the balance is zero — so on an incompatible conduit it returns a confident {shares: 0, assets: 0} rather than surfacing an error.

Revisit this once the SDK is regenerated against the current deployment.