Skip to main content

Class: EvmWriter

Defined in: evm-client/src/writer.ts:100

EVM implementation of OvercastWriter: the write side of the protocol, yielding built-but-unsubmitted EvmLayer.txs that the caller signs and sends (or hands to SigningWriter, which does both).

It builds calldata; it does not decide who signs

Every write is a call to the one Core proxy, so a built transaction is nothing but { to: core, data, value: 0n } — the sole exception being the ERC-20 approve that deposit and repayCollateral prepend, which is addressed to the token. Unlike Solana — where the writer has to resolve a dozen PDAs, a fee payer and a token program per instruction — there is no account resolution to do here at all: core keeps offers and options as nothing but the id their terms hash to, and vault balances and both claim ledgers in its own storage.

What that costs is calldata rather than lookups: every write against an existing offer or option submits its whole terms, since core has none of its own to work from — it re-derives the id from what is handed in and requires it to be registered. The writer never has to fetch anything to build one, and a caller holding only an id has to get the terms elsewhere (the curated backend) first.

That is also why this class takes no account. On the direct path core's authorization is msg.sender-based (createOffer requires the offer's creator, cancelOffer the submitted offer's creator, borrowCollateral the option's taker, withdraw the account holder; matchOffer, exerciseOption, redeemOption and repayCollateral are permissionless or claim-gated), and the sender is chosen by whoever submits, not by whoever builds. Baking a from in here would only be a guess, and it would take the keyless build path — a browser wallet, a relayer, a multisig — away from a read-only app. The cost is that a mismatch surfaces as Unauthorized() at submit time rather than as a throw here.

The exceptions are deposit and repayCollateral, the two calls where core pulls tokens from msg.sender: each reads an ERC-20 allowance to decide whether the operation needs an approval transaction in front of it. deposit reads it for the depositor (see its doc comment for what that cannot know), repayCollateral for the configured account holder.

Delegated operations

Every delegable call has a *Signed twin on core, and the operation argument is what selects it: createOffer encodes createOfferSigned, and likewise through cancelOffer, exerciseOption, redeemOption and withdraw. The twin takes the same arguments plus an authorization envelope, and resolves who is acting from that envelope instead of from msg.sender — so on this path the submitter is an operator with no authority of its own, and the acting party never touches the transaction. Which is also why the msg.sender reasoning above stops applying once an operation is present: the sender is then deliberately somebody else, and this class not knowing who submits is the point rather than the cost.

Note what the writer does not have to do here. Core rebuilds the signed payload on chain from the terms submitted alongside the envelope — the offer itself for a creation or a cancellation, the option's two offer ids for a settlement — and recomputes the digest itself. So there is no EVM-side payload encoding to keep in step with core's Operation.payloadBytes, and the envelope is the only thing that crosses. fromSignedOperation is that boundary, including the one respect in which an EVM operation is not a Solana one (secp256k1, recovered rather than supplied).

Authorization is per-call, not a standing operator list: the recovered signer must be the acting party itself, or an address that party has granted the action to. Granting and revoking are setAuthorizedKey / removeAuthorizedKey, which on this chain authorize an address rather than a key — see the former for why that is the same call under a Solana-shaped name, and why a party signing for itself needs no grant.

Implements​

Constructors​

Constructor​

new EvmWriter(
client,
coreAddress,
accountHolder?): EvmWriter;

Defined in: evm-client/src/writer.ts:116

Parameters​

ParameterTypeDescription
client{ }a viem public client on the chain core is deployed to. Only deposit and repayCollateral use it (for one allowance read each); every other method builds offline, so an account-less transport is enough and a read-only app can build transactions it has no key to sign.
coreAddressstringthe deployed Core proxy address (see EvmConfig.overcastCoreAddress) — not the implementation behind it.
accountHolder?`0x${string}`the configured account that will submit, if any. Only repayCollateral uses it, as the wallet whose allowance is read; without one, repayment is built with no approval check.

Returns​

EvmWriter

Properties​

coreAddress​

readonly coreAddress: `0x${string}`;

Defined in: evm-client/src/writer.ts:102

The deployed Core proxy every call below is addressed to.

Methods​

borrowCollateral()​

borrowCollateral(
option,
amount,
metadata?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:340

Draw amount of the option's escrowed collateral out to the taker's own wallet, tracked as an outstanding borrow on the option.

The taker must submit this themselves — core checks msg.sender against option.taker and there is no signed twin — and the collateral is sent straight to that same taker: core takes the receiver as an argument, but the chain-agnostic interface has no field for a third-party receiver, so this passes option.taker rather than inventing one.

Core refuses outright unless option.details.borrowAllowed is set and the exercise window has not opened yet, and the option's settlement layer gets the final say through canBorrow (which metadata is forwarded to). While anything is outstanding neither exercise nor redemption can run, and repayment is trusted rather than enforced.

Parameters​

ParameterType
optionMarketOption
amountbigint
metadata?Metadata

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.borrowCollateral

cancelOffer()​

cancelOffer(offer, operation?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:182

Cancel a standing offer, releasing its escrowed deposit back into the creator's vault balance.

Takes — and submits — the whole offer rather than its id, because core keeps only the id: hashing the terms as committed is what resolves the entry it stored, and the terms are also what tell it which escrow to release. An offer that does not hash to a stored id — because a field drifted, or because it was already cancelled or is under a different salt — reverts with OfferNotFound.

Not gated on the offer's expiry: an expired offer can no longer be matched, so this is the only path by which its deposit comes back.

With an operation, the acting party is the submitted offer's creator, and the signed payload is the bare offerId core derives from these terms — so a cancellation signature is bound to one specific offer and to nothing else.

Parameters​

ParameterType
offerOffer
operation?SignedOperation

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.cancelOffer

createOffer()​

createOffer(offer, operation?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:141

Register a standing offer, escrowing its collateral (Maker side) or premium (Taker side) out of the creator's vault.

The offer is submitted verbatim, plus the zero counterparty the core Offer implies (see fromOffer), so core keys it under exactly the getId(offer) this SDK derives. The funds are moved out of the creator's vault balance, not pulled from their wallet — so a deposit has to have landed first, and creation itself makes no token call.

Without an operation, offer.creator must be the account that submits this transaction. With one, core takes offer.creator as the acting party and checks the signature over the offer's own 351-byte serialization against it — the same offerBytes preimage getId hashes — so the offer authorized and the offer stored cannot differ in any field.

Parameters​

ParameterType
offerOffer
operation?SignedOperation

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.createOffer

deposit()​

deposit(deposit): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:436

Credit deposit.amount of deposit.token to deposit.depositor's vault balance, pulling the tokens from the submitting wallet — preceded by the ERC-20 approval that pull needs, when there is not already one.

This is the protocol's only inbound token transfer: everything above the vault — offer creation, matching, exercise, redemption — moves value between ledgers, so funds have to arrive here first. Core pulls with safeTransferFrom(msg.sender, …), which means the submitter must have approved the core proxy for at least this amount, and a missing approval reverts with a bare SafeERC20FailedOperation(address) that says nothing about allowances.

So this reads on-chain state before building (as repayCollateral does): it checks allowance(depositor, core) to decide which of the two it is.

The approval is for exactly deposit.amount, not unbounded: core pulls the full amount in the very next transaction, so an exact approval is spent to zero as it is used and leaves nothing standing afterwards. The cost is one approval per deposit. Note it sets the allowance rather than adding to it, so a partial existing allowance is replaced, not topped up.

What the allowance read cannot know. Core lets anyone fund someone else's vault, deliberately — the puller is msg.sender and the credited account is a separate argument. This writer takes no account (see the class doc), so deposit.depositor is the only candidate sender it has, and the two coincide in every case but third-party funding. Funding another account's vault from a different wallet therefore reads the wrong allowance: harmless when it under-reads (the submitting wallet approves an amount it was going to need anyway), but when the depositor holds an allowance the submitter does not, the approval is omitted and the deposit reverts as it would have before. Approve from the submitting wallet in that case.

Parameters​

ParameterType
depositDepositParams

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.deposit

exerciseOption()​

exerciseOption(
option,
amount,
params?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:285

Burn amount of the submitter's exercise claims and take delivery against them.

Holding the claims is the entire permission — core burns from msg.sender and moves both legs against msg.sender's own vault balances, deliberately not checking the caller against option.taker, since the claims are an ERC-20 token and are meant to be transferable. So the settlement asset has to be in the exerciser's vault balance already, and the collateral is paid into it rather than sent on.

The option is submitted as its whole terms: core stores only the id, and re-derives it from what is handed in — which is also what supplies the settlement layer, the two amounts and the exercise window it checks. Terms that hash to no registered option revert with OptionNotFound.

params.metadata is forwarded to the option's settlement layer verbatim (see ISettlementLayer.canExercise); it is unauthenticated caller input as far as the layer is concerned. params.settlement is accepted and dropped: EvmLayer.settlementContext is empty because a layer here is a contract that reads whatever it needs itself, so there is no counterpart to Solana's list of forwarded accounts.

With params.operation, the acting party is taken to be option.taker. Core itself no longer requires that — exerciseOptionSigned takes the acting party as an argument, so a third party who acquired the exercise claims could delegate too — but core's SignedOperation envelope carries no acting party, and the signature covers (actingParty, collateralOffer, settlementOffer, amount, metadata), so anything else here would be a guess at what was signed.

TODO: carry the acting party on the operation (or in SettlementParams) so a claim holder other than the taker can delegate an exercise.

Parameters​

ParameterType
optionMarketOption
amountbigint
paramsSettlementParams<EvmLayer>

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.exerciseOption

matchOffer()​

matchOffer(settlementOffer, collateralOffer): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:231

Match a Maker (collateral) offer against a Taker (settlement) offer, minting the option they both describe and issuing its two claim tokens.

Permissionless: both counterparties have already committed their funds and their terms, so the submitter chooses nothing except that these two offers meet — it need not be either party.

Both offers go over the wire in full. Core stores offers by id alone and has no terms of its own to build the option out of, so the terms it hashes and escrows against are the ones submitted here; it re-derives each offer's id from them and requires both to be registered.

Note the argument order. This method takes the settlement offer first, matching OvercastWriter.matchOffer, while core's matchOffer takes the collateral offer first — the two are swapped on the way in. Getting that wrong reverts with OfferMismatch rather than mis-minting: core requires the first offer to be the Maker side.

The bytes metadata core forwards to the configured IOptionCreationHook is sent empty. It is the submitter's own input rather than anything the counterparties agreed to, so the chain-agnostic signature has no field for it, and no currently deployed hook reads any — ProtocolConfig. optionCreationHook is unset everywhere, in which case core ignores the bytes entirely.

Parameters​

ParameterType
settlementOfferOffer
collateralOfferOffer

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.matchOffer

redeemOption()​

redeemOption(
option,
amount,
params?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:316

Burn amount of the submitter's redemption claims and pay out their share of both of the option's escrows.

The mirror of exerciseOption, and claim-gated the same way: core burns from msg.sender and pays into their vault balance, without checking them against option.maker.

Only reachable once exercise is closed — after details.endTimestamp (strictly, so the two windows never overlap), or as soon as every exercise claim has been burned, since the option's outcome is final at that point and there is nothing left to wait for.

params.metadata, params.settlement and params.operation behave exactly as in exerciseOption — including the acting party, with option.maker in place of option.taker. The signed payload layout is byte-identical to the exercise one; what keeps the two signatures from being interchangeable is the action discriminant bound into the digest.

Unlike Solana's writer, this one has no special case for redemption: core consults the layer on both paths through the same kind of call, so there is nothing here that exercise does and redeem does not.

Parameters​

ParameterType
optionMarketOption
amountbigint
paramsSettlementParams<EvmLayer>

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.redeemOption

removeAuthorizedKey()​

removeAuthorizedKey(key): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:570

Revoke a delegate authorized by setAuthorizedKey. Operations signed by key are rejected with UnauthorizedSigner afterwards.

setDelegate with an empty bitmap is the revocation — core reads a zero as "no actions", so there is no separate entry point and no record to close (nor, unlike Solana, any rent to refund). Revoking a delegate that was never authorized therefore succeeds and changes nothing, rather than reverting.

Note what this does not undo: an operation the delegate already signed but nobody has submitted yet stops being valid, since authorization is checked at execution — but one already executed is done, and its nonce stays consumed. There is no way to invalidate outstanding operations wholesale, because the replay guard is an unordered (signer, nonce) set, not a sequential counter with a high-water mark to raise.

Parameters​

ParameterType
keyBytes32

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.removeAuthorizedKey

repayCollateral()​

repayCollateral(option, amount): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:379

Return amount of borrowed collateral to the option's escrow, reducing its outstanding borrow.

Permissionless, unlike borrowCollateral: core pulls the asset from msg.sender, so any wallet holding the collateral may repay on the taker's behalf — and must therefore have approved the core proxy for the amount. The settlement layer is not consulted.

Like deposit, this reads the allowance first and prepends an exact approve of option.details.collateralAsset when it falls short. The allowance read is against the writer's account holder — the wallet that submits, and so the one core pulls from — not option.taker, who need not be the repayer. A writer built without an account (read-only) cannot know the repayer, so it skips the check and returns the repayment alone.

Parameters​

ParameterType
optionMarketOption
amountbigint

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.repayCollateral

setAuthorizedKey()​

setAuthorizedKey(key, permissions?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:547

Authorize key to sign this wallet's delegated operations, for the actions permissions names.

key is an address here, not a public key

The interface's name is Solana's: there, a (owner, key) record stores an ed25519 public key, and the program checks a submitted operation against it. Core has no such record — it recovers the signer from the signature (see fromSignedOperation) and looks that up in a party => delegate => permissions map, so what gets authorized is an account, and no public key is ever named on chain.

Those two coincide well enough for this to be the same call, because an EVM account is the identity of its key. So key is read as the canonical slot of a 20-byte address — the encoding OvercastUtils.toBytes32 already produces on this chain, and what overcast key add --key 0x… hands over. A slot with non-zero high bytes throws: that is an ed25519 key (or a digest) reaching an address position, and it could only ever authorize an account that does not exist.

Two consequences worth knowing before granting one:

  • The delegate must be an EOA. _verifySignedOperation recovers with plain ECDSA.recover and consults no ERC-1271 fallback, so a smart-contract wallet cannot be a delegate however it signs.
  • A party never needs to authorize itself. The signer check passes when the recovered signer is the acting party, so self-signed operations work with no grant at all. This call is only for a third party signing.

permissions is a per-action bitmap, bit n granting action n — the same layout on both chains, since core's ACTIONS and Solidity's IntentAction enumerate in the same order (build it with buildPermissionsBitmap). It has to fit the contract's uint16. Omitted, it defaults to every bit set, per OvercastWriter.setAuthorizedKey; the bits above the five defined actions are inert, since core only ever tests one action at a time.

The granting party is msg.sender, so this is one of the calls that must be submitted by the wallet it acts for — you can only delegate your own authority, and there is no signed variant of it.

Parameters​

ParameterTypeDefault value
keyBytes32undefined
permissionsnumberALL_PERMISSIONS

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.setAuthorizedKey

withdraw()​

withdraw(withdraw, signedOperation?): Promise<TransactionRequest & object[]>;

Defined in: evm-client/src/writer.ts:477

Move withdraw.amount of withdraw.token out of withdraw.withdrawer's vault balance and send it to them.

The protocol's only outbound token transfer, and the mirror of deposit — but not symmetric in who may call it: core requires msg.sender to be the account holder, so unlike a deposit a direct withdrawal cannot be submitted on someone else's behalf. A signedOperation is how that is done instead, and core sends the tokens to the account holder either way — never to a submitting operator, so there is nothing an operator can redirect. No allowance is involved (core already holds the tokens), so nothing is read before building.

The delegated entry point takes the same three flat arguments plus the envelope; the signed digest is over asset | accountHolder | amount in the shared cross-chain layout, matching core's WithdrawOperation — note that order is not the call's argument order.

Parameters​

ParameterType
withdrawWithdrawParams
signedOperation?SignedOperation

Returns​

Promise<TransactionRequest & object[]>

Implementation of​

OvercastWriter.withdraw