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:
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.assetmust be the Vehicle, not the Conduit.Conduit._createforwards your query tovehicle.create(query)verbatim, andBaseVehicle._validateOutputrequiresoutput.asset == address(vehicle)on a deposit. Passing the conduit address — or a zero address — reverts0x3fbec7fa. Read it withgetVehicle().
query.saltis bound to the caller.create()reverts unlessquery.salt == keccak256(abi.encode(msg.sender, sourceSalt)). That binding is what stops anyone else occupying your queryId. Keep thesourceSalt— nothing on-chain lets you recover it, andprocess()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
_createburns your cShares with an internal_transferthat never reachestransferFrom. An approve here is a wasted signature and a wasted transaction.
The salt is passed raw.
createRedeemFromConduitSharesderivesquery.saltfrom(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_PROCESSrole exists, but no published keeper job covers conduit queries. Until that is settled, treat user-drivenprocess()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:
| call | SDK shape | this deployment |
|---|---|---|
convert | 0x31d6c8ab | 0x500af719 |
create | 0x0f292594 | 0x50bb3af4 |
createRedeemFromConduitShares | 0x34043465 | 0x4c717f8d |
estimate | 0xc1bad5eb | 0xd34eca33 |
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.