For the complete documentation index, see llms.txt. This page is also available as Markdown.

TownSquare Vault Setup

TownSqVault — Deploy, Deposit, Withdraw

This repo contains TownSqVault, an upgradeable ERC-4626 vault that supplies deposits into TownSq via spokeTokenOperations.topup(...), and uses a 2-step withdrawal flow:

  • Step 1: initiateWithdraw(shares, receiver, owner) initiates the withdrawal and burns shares.

  • Step 2: After a timelock (currently 6 hours), withdraw(owner) finalizes by transferring assets to receiver (or mints shares back if transfer fails).


What gets deployed

TownSqVault is written as an upgradeable contract and is intended to be used behind a proxy.

  • Implementation: src/TownSqVault.sol

  • Proxy pattern: TransparentUpgradeableProxy

  • Initializer: TownSqVault.initialize(...) (called once via proxy constructor or upgrade tooling)


Deployment (Foundry)

Prerequisites

  • Foundry installed (forge, cast)

  • RPC URL for your target network (the script is currently set up for chainId = 143)

  • A deployer private key loaded in your environment (examples use Foundry’s --sender + broadcast key)

Parameters you must supply

TownSqVault.initialize(...) requires:

  • Owner: _owner (admin of vault settings like fees)

  • TownSq addresses:

    • _spokeOperations

    • _spokeTokenOperations

    • _assetHubPool

    • _loanManager

  • Asset: _asset (ERC20 address; current script uses USDC)

  • Pool Id: _assetPoolId (vault collateral pool id in TownSq)

  • Timelock: initialTimelock (vault’s configurable timelock param; separate from withdrawal init timelock)

  • Chain / loan identity:

    • chainId

    • vaultLoanTypeId

    • nonce

    • loanName

  • ERC20 share token metadata:

    • _name

    • _symbol

  • Messaging params: _vaultMessageParams (Messages.MessageParams)

Example values (from script/TownSqVault.s.sol)

Your script currently uses (chain 143):

  • initialOwner: 0xa031f11d7CDF039eeF0e73E47Bd5B487f3659B65

  • USDC: 0x754704Bc059F8C67012fEd69BC8A327a5aafb603

  • spokeOperations: 0x63CB1CF5aCCbCC57e0cCa047bE9673EA5022b8DB

  • spokeTokenOperations: 0xA457235B68606a7921b7c525D92e9592e793b4C0

  • assetHubPool: 0xdb4E67F878289A820046f46f6304fd6Ee1449281

  • loanManager: 0xC4C20EFbEfA4Bde14091a3040d112cF981d8B2DB

  • vaultAssetPoolId: 10

  • vaultLoanTypeId: 1

  • nonce: 0x00000001

  • loanName: "USDC VAULT"

  • name/symbol: "USDC VAULT" / "vUSDC"

  • messageParams: { adapterId: 1, returnAdapterId: 1, receiverValue: 0, gasLimit: 5_000_000, returnGasLimit: 5_000_000 }

The canonical flow is:

  1. Deploy TownSqVault implementation.

  2. Encode initializerData = abi.encodeCall(TownSqVault.initialize, (...)).

  3. Deploy TransparentUpgradeableProxy(impl, admin, initializerData).

You already do this in tests (test/TownSqVault.t.sol).

Running the script

There is a working Foundry invocation note at the bottom of script/TownSqVault.s.sol:

Notes:

  • --broadcast writes transactions + receipts into broadcast/....

  • If you’re deploying a new vault proxy, ensure your script uses the proxy-constructor deployment pattern (implementation + TransparentUpgradeableProxy) and not only an upgrade call.


Depositing

User flow (ERC-4626 deposit)

To deposit assets of the underlying token:

  1. Approve the vault to spend your asset.

  2. Call deposit(assets, receiver) on the vault.

In Solidity terms:

  • User calls TownSqVault.deposit(assets, receiver)

  • Vault:

    • accrues interest (_accrueInterest())

    • calculates shares using lastTotalAssets and current totalSupply()

    • transfers the ERC20 assets into the vault (ERC-4626 _deposit)

    • supplies to TownSq by calling spokeTokenOperations.topup(...)

    • updates lastTotalAssets += assets

Example (cast)

Replace:

  • $VAULT with the proxy address

  • $ASSET with the underlying asset (e.g. USDC)

  • $AMOUNT with the amount in smallest units (USDC has 6 decimals)

Shares / decimals note

The vault’s share token uses 18 decimals (ERC20 default), and computes an internal decimalOffset as:

  • decimalOffset = 18 - ERC20(asset).decimals()

So for USDC (6 decimals), decimalOffset = 12. This is why in tests a USDC deposit can mint “18-decimal shares”.


Withdrawing (2-step flow)

Withdrawals are not done via the standard ERC-4626 withdraw(...) / redeem(...) path in this contract. Instead, the user must use:

  1. initiateWithdraw(shares, receiver, owner)

  2. wait 6 hours

  3. withdraw(owner)

Step 1 — initiateWithdraw

Call:

  • initiateWithdraw(uint256 shares, address receiver, address owner)

What it does:

  • Reverts if owner already has a pending withdrawal (ErrorsLib.AlreadyInitiated()).

  • Accrues interest.

  • Converts shares → assets using current totals (rounding up).

  • Calls _withdrawFromTownSq(assets, owner, receiver):

    • Ensures requested assets don’t exceed vault’s expected supply.

    • Enforces liquidity via _withdrawable() (deposit minus borrows from assetHubPool).

    • Stores initiatedWithdrawal[owner] = { receiver, assets, shares, timeInitiated, true }

    • Burns the owner’s shares immediately.

    • Updates lastTotalAssets down.

    • Calls spokeOperations.withdraw(...) to start withdrawing from TownSq.

If TownSq liquidity is insufficient, this step can revert with:

  • ErrorsLib.NotEnoughLiquidity()

Step 2

The finalize call requires a minimum delay:

  • ConstantsLib.WITHDRAW_INITIALIZATION_TIMELOCK = 6 hours

If you call finalize too early:

  • ErrorsLib.WithdrawTimeLockNotExpired()

Step 3 — withdraw (finalize)

Call:

  • withdraw(address owner)

What it does:

  • Reverts if there is no initiated withdrawal (ErrorsLib.NoInitiatedWithdrawal()).

  • Reverts if 6 hours hasn’t elapsed.

  • Attempts to transfer assets to receiver via safeTransferExternal(receiver, assets).

    • If the transfer succeeds: emits EventsLib.AssetWithdraw(owner, receiver, assets) and clears the pending state.

    • If the transfer fails (caught by try/catch): it mints back the burned shares to receiver and clears the pending state.

This “mint-back” fallback ensures the user isn’t stuck permanently if the final transfer cannot be executed for some reason.

Example (cast)

Assuming you want to withdraw all shares you own:


Operational notes

  • Liquidity matters: initiateWithdraw enforces available liquidity using assetHubPool totals. If TownSq has outstanding borrows, withdrawals may revert until liquidity is available.

  • Interest + fees: the vault accrues interest on interaction (deposit, initiateWithdraw) via _accrueInterest(). If fee != 0, it mints fee shares to feeRecipient.

  • Timelock variables:

    • WITHDRAW_INITIALIZATION_TIMELOCK is a hard-coded constant (6 hours).

    • timelock is a separate vault parameter set in initialize() via _setTimelock(initialTimelock) and bounded for non-zero values.

Last updated