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
OvercastWriter<EvmLayer>
Constructors
Constructor
new EvmWriter(
client,
coreAddress,
accountHolder?): EvmWriter;
Defined in: evm-client/src/writer.ts:116
Parameters
| Parameter | Type | Description |
|---|---|---|
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. |
coreAddress | string | the 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
| Parameter | Type |
|---|---|
option | MarketOption |
amount | bigint |
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
| Parameter | Type |
|---|---|
offer | Offer |
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
| Parameter | Type |
|---|---|
offer | Offer |
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
| Parameter | Type |
|---|---|
deposit | DepositParams |
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
| Parameter | Type |
|---|---|
option | MarketOption |
amount | bigint |
params | SettlementParams<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
| Parameter | Type |
|---|---|
settlementOffer | Offer |
collateralOffer | Offer |
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
| Parameter | Type |
|---|---|
option | MarketOption |
amount | bigint |
params | SettlementParams<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
| Parameter | Type |
|---|---|
key | Bytes32 |
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
| Parameter | Type |
|---|---|
option | MarketOption |
amount | bigint |
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.
_verifySignedOperationrecovers with plainECDSA.recoverand 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
| Parameter | Type | Default value |
|---|---|---|
key | Bytes32 | undefined |
permissions | number | ALL_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
| Parameter | Type |
|---|---|
withdraw | WithdrawParams |
signedOperation? | SignedOperation |
Returns
Promise<TransactionRequest & object[]>
Implementation of
OvercastWriter.withdraw