Migrate from env.events().publish() to #[contractevent] macro (typed events) - #620
Open
ZuLu0890 wants to merge 1 commit into
Open
Migrate from env.events().publish() to #[contractevent] macro (typed events)#620ZuLu0890 wants to merge 1 commit into
ZuLu0890 wants to merge 1 commit into
Conversation
Replace all env.events().publish() tuple emissions with typed #[contractevent] structs across lib.rs and the escrow, streams, yield_escrow, batch_send, airdrop, and multi_sig modules. Remove #[allow(deprecated)] and update the backend event parser, indexer, and push notifier to read the new named-field data Map format. Fixes a latent off-by-one in the MultisigApproved approval count. Closes FinChippay#554 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔧 Migrate from
env.events().publish()to#[contractevent]— Typed, Schema-Enforced EventsTable of Contents
Summary
Replaces every
env.events().publish((Symbol, ...), data)call in theFinchippay contract with a typed
#[contractevent]struct, emitted via thenon-deprecated
env.events().publish_event(&event). Events are nowschema-enforced: the struct's snake-case name is the first topic and every
field is a named entry in the event data Map, so indexers and generated SDK
clients deserialize a stable, typed event instead of guessing at tuple layout.
#[contractevent]structs addedpublish()→publish_event()call sites migratedeventsmodule#[allow(deprecated)]removedProblem Statement
The contract emitted events through the low-level
env.events().publish((Symbol, ...), data)API, which the Soroban SDK (≥ 22.0)has deprecated. This caused five distinct problems:
cargo checkproduced 33 deprecation warnings, and theentire
FinchippayContractimpl block carried#[allow(deprecated)]. Thecontract could not be upgraded past an SDK that removes
publish().Adding/removing a field silently broke downstream indexers, dashboards, and
SDK consumers. (The
events.rscatalog had already drifted from the actualemitted names, e.g. the catalog said
tip_sent/escrow_createdwhile thecode emitted
tip/escrow_create.)getEventstopic filtering andgenerated event listeners could not consume ad-hoc tuple events.
pre-mainnet concern.
Solution Overview
On-chain, the new event serializes as:
["escrow_created"]— the struct's snake-case name (aSymbol).Map { "escrow_id": 0, "from": G…, "to": G…, "amount": "…", "release_ledger": … }—a
SCV_MAPkeyed by field-name symbols.Design Decisions
1.
env.events().publish_event(&event)rather thanevent.publish(&env)The issue specified
env.events().publish(&event_struct). The SDK's actualnon-deprecated method is
Events::publish_event(&impl Event); the barepublishremains the deprecated two-argument tuple API.publish_eventisused consistently so every call site reads as a mechanical replacement of the
old
publish.2. Default (
map) data formatEvery struct uses the
#[contractevent]defaultdata_format = "map", sofields are emitted as a
SCV_MAPkeyed by field-name symbols. This is what theissue asks for ("typed event structs with named fields") and lets the backend
parser address fields by name rather than by tuple position.
3. Struct-name topics (intentional schema normalization)
The first topic is the struct's snake-case name. This normalizes the handful of
ad-hoc legacy topic strings to the names already documented in
events.rs:tiptip_sent(TipSent)receiptreceipt_minted(ReceiptMinted)escrow_createescrow_created(EscrowCreated)escrow_claimescrow_claimed(EscrowClaimed)stream_openstream_opened(StreamOpened)stream_claimstream_claimed(StreamClaimed)multisig_createmultisig_created(MultisigCreated)multisig_approvemultisig_approved(MultisigApproved)All other event names are preserved verbatim.
4. Backend parser decodes SCVal XDR
backend/src/services/eventParser.jsnow decodes base64SCValXDR using@stellar/stellar-sdk, converting addresses to StrKey (G…/C…) and integersto precision-safe strings, then extracts
from/to/amountfrom the nameddata fields via an updated
EVENT_PARTICIPANT_MAP. It also degrades gracefullyto already-decoded (plain-object) input for tests.
5.
events.rsmodule promoted into the crateevents.rspreviously existed as an orphan file (not declared in the moduletree). It is now
pub mod events;inlib.rs, holding the typed structsalongside the maintained catalog documentation.
Complete Event Migration Map
lib.rs— admin, governance, tips, receipts, swap, TTL, emergencypublish()("init",)→adminInit(init)admin: Address("admin_transfer",)→new_adminAdminTransfer(admin_transfer)new_admin: Address("paused",)→()Paused(paused)("unpaused",)→()Unpaused(unpaused)("pauser_set",)→pauserPauserSet(pauser_set)pauser: Address("admin_signers_set",)→(threshold, len)AdminSignersSet(admin_signers_set)threshold: u32,signer_count: u32("upgraded",)→(ver, hash, layout)Upgraded(upgraded)new_version: u32,wasm_hash: BytesN<32>,layout_version: u32("ttl_bumped",)→(bumped, ci, ki)TtlBumped(ttl_bumped)keys_bumped: u32,class_index: u32,key_index: u32("admin_action_proposed",)→(id, type, proposer)AdminActionProposed(admin_action_proposed)proposal_id: u64,action_type: Symbol,proposer: Address("admin_action_approved",)→(id, approver, n, th)AdminActionApproved(admin_action_approved)proposal_id: u64,approver: Address,count: u32,threshold: u32("rescue_tokens",)→(token, amount, to)RescueTokens(rescue_tokens)token: Address,amount: i128,to: Address("fee_collector_set",)→collectorFeeCollectorSet(fee_collector_set)collector: Address("swap_fee_set",)→bpsSwapFeeSet(swap_fee_set)fee_bps: u32("swap", caller, in, out)→(in, out, fee)Swap(swap)caller,token_in,token_out,amount_in,amount_out,fee("tip", from, to)→amountTipSent(tip_sent)from,to,amount: i128,ledger: u32,memo: Symbol("receipt", from)→countReceiptMinted(receipt_minted)payer: Address,receipt_index: u32("emergency_withdrawal_initiated", id)→(admin, token, amt, act)EmergencyWithdrawalInitiated(emergency_withdrawal_initiated)withdrawal_id: u32,initiator,token,amount,activation_ledger: u32("emergency_withdrawal_approve", id)→(signer, n, th)EmergencyWithdrawalApproved(emergency_withdrawal_approved)withdrawal_id,signer,count: u32,threshold: u32("emergency_withdrawal_executed", id)→(to, amount)EmergencyWithdrawalExecuted(emergency_withdrawal_executed)withdrawal_id,to,amount("emergency_withdrawal_cancelled", id)→(admin, amount)EmergencyWithdrawalCancelled(emergency_withdrawal_cancelled)withdrawal_id,admin,amountescrow.rspublish()("escrow_create", id)→(from, to, amount, release)EscrowCreated(escrow_created)escrow_id,from,to,amount,release_ledger("escrow_claim_partial", id)→(to, claimed, remaining)EscrowClaimPartial(escrow_claim_partial)escrow_id,to,claim_amount,remaining("escrow_claim", id)→(to, amount)EscrowClaimed(escrow_claimed)escrow_id,recipient,amount("escrow_cancelled",)→(id, from, amount)EscrowCancelled(escrow_cancelled)escrow_id,from,amount("disputable_escrow_created",)→(id, arb)DisputableEscrowCreated(disputable_escrow_created)escrow_id,arbitrator("dispute_raised",)→(id, by)DisputeRaised(dispute_raised)escrow_id,raised_by("dispute_resolved",)→(id, res, to, amount)DisputeResolved(dispute_resolved)escrow_id,resolution: Symbol,to,amount("arbitrator_added",)→arbitratorArbitratorAdded(arbitrator_added)arbitrator("arbitrator_removed",)→arbitratorArbitratorRemoved(arbitrator_removed)arbitratorstreams.rspublish()("stream_open", id)→(payer, recipient, rate, deposit)StreamOpened(stream_opened)stream_id,payer,recipient,rate: i128,deposit: i128("stream_claim", id)→(recipient, claimable)StreamClaimed(stream_claimed)stream_id,recipient,amount("stream_topped_up",)→(id, payer, amount, deposited)StreamToppedUp(stream_topped_up)stream_id,payer,amount,deposited("stream_close", id)→(payer, refund)StreamClose(stream_close)stream_id,payer,refund("stream_closed", id)→(refund, claimable)StreamClosed(stream_closed)stream_id,refund,claimable("stream_reject", id)→(recipient, refund)StreamReject(stream_reject)stream_id,recipient,refund("stream_transfer", id)→(old, new)StreamTransfer(stream_transfer)stream_id,from,tobatch_send.rspublish()("tip", from, to)→(amount, memo)TipSent(tip_sent)from,to,amount,ledger,memo("batch_sent",)→(from, n, total)BatchSent(batch_sent)sender,recipient_count: u32,total_amount: i128("batch_sent_multi",)→(from, n, total)BatchSentMulti(batch_sent_multi)sender,recipient_count,total_amount("vesting_create", id)→(from, ben, amount, cliff, end)VestingCreate(vesting_create)vesting_id,from,beneficiary,amount,cliff_ledger,end_ledger("vesting_claim", id)→(beneficiary, claimable)VestingClaim(vesting_claim)vesting_id,beneficiary,amount("vesting_revoke", id)→(funder, unclaimed)VestingRevoke(vesting_revoke)vesting_id,funder,amountmulti_sig.rspublish()("multisig_create", id)→(proposer, recipient, amount, threshold)MultisigCreated(multisig_created)proposal_id,proposer,recipient,amount,threshold,signers_count: u32,expiration_ledger: u32("multisig_approve", id)→(signer, n+1, threshold)MultisigApproved(multisig_approved)proposal_id,approver,count: u32,threshold: u32("multisig_executed", id)→(recipient, amount)MultisigExecuted(multisig_executed)proposal_id,recipient,amount("multisig_timeout", id)→(proposer, amount)MultisigTimeout(multisig_timeout)proposal_id,proposer,amount("multisig_cancelled",)→(id, proposer, amount)MultisigCancelled(multisig_cancelled)proposal_id,proposer,amountairdrop.rspublish()("airdrop_created", id)→(funder, token, total)AirdropCreated(airdrop_created)airdrop_id,funder,token,total_amount("airdrop_claimed", id)→(recipient, amount)AirdropClaimed(airdrop_claimed)airdrop_id,recipient,amount("airdrop_cancelled", id)→(funder, unclaimed)AirdropCancelled(airdrop_cancelled)airdrop_id,funder,amountyield_escrow.rspublish()("yield_escrow_create", id)→(from, to, token, amount, shares)YieldEscrowCreate(yield_escrow_create)escrow_id: u64,from,to,token,amount,shares("yield_escrow_claim", id)→(to, total)YieldEscrowClaim(yield_escrow_claim)escrow_id: u64,to,amount("yield_escrow_cancelled", id)→(from, refund)YieldEscrowCancelled(yield_escrow_cancelled)escrow_id: u64,from,amountFiles Changed
Contract
contracts/finchippay-contract/src/events.rs#[contractevent]structs + full event catalog docs (previously an orphan file with aSymbolfactory that nothing used)contracts/finchippay-contract/src/lib.rspub mod events;+use crate::events::*;; 27 sites →publish_event; removed#[allow(deprecated)]; rewrote 5 event-assertion unit tests to.to_xdr()contracts/finchippay-contract/src/escrow.rspublish_eventcontracts/finchippay-contract/src/streams.rspublish_event; dropped now-unusedSymbolimportcontracts/finchippay-contract/src/batch_send.rspublish_eventcontracts/finchippay-contract/src/multi_sig.rspublish_event; dropped now-unusedSymbolimportcontracts/finchippay-contract/src/airdrop.rspublish_event; dropped now-unusedSymbolimportcontracts/finchippay-contract/src/yield_escrow.rspublish_eventcontracts/finchippay-contract/README.mdEvents emittedtable + marked the migration completeBackend
backend/src/services/eventParser.jsEVENT_PARTICIPANT_MAP, named-field extractionbackend/src/services/eventIndexer.jsbackend/src/services/pushNotifier.jsEVENT_NOTIFICATIONSkeys updated to typed names (so push notifications keep matching)backend/__tests__/integration-eventIndexer.test.jstip_sentevent shapeBackend Indexer & Parser Changes
The old parser treated
topic[0]as the event name and pulledfrom/tofromtopic[1]/topic[2]positionally. With typed events, the name is stilltopic[0]but all fields now live in the data Map.parseEvent(raw)now:@stellar/stellar-sdk'sxdr.ScVal.(plain objects pass through untouched for tests).
G…/C…StrKey,i128/u128/u64→ precision-safe decimal strings, maps → named objects.{ from, to, amount }field names inEVENT_PARTICIPANT_MAPand populatesfrom_addr,to_addr,amount_raw.The
EVENT_PARTICIPANT_MAPnow covers all 53 typed events. Consumers of theindexed rows (
queryEventsByPublicKey,queryEventsByType) are unchanged —they match on the serialized payload, which now contains the decoded named
fields.
Verification
All commands were run locally (Rust
stable, targetwasm32v1-none):Test binaries:
src/lib.rsunit teststests/integration.rstests/property_streaming.rstests/batch_swap.rstests/gas_profile.rsBackend parser (syntax + functional smoke test against real base64 SCVal XDR
and plain-object mocks):
Typed round-trip unit tests assert against the emitted
xdr::ContractEventusing the generated
Event::to_xdr(&env, &contract_id):test_cancel_escrow_emits_escrow_cancelled_event→EscrowCancelledtest_top_up_stream_emits_stream_topped_up_event→StreamToppedUptest_cancel_multisig_emits_multisig_cancelled_event→MultisigCancelledtest_batch_send_emits_batch_sent_event→TipSent×2 +BatchSenttest_rescue_tokens_emits_rescue_tokens_event→AdminActionProposed+AdminActionApproved+RescueTokensAcceptance Criteria Checklist
publish()calls replaced with typed structspublish_eventsites;grep -rn "events().publish(" src/→ nothing#[allow(deprecated)]removed from contract impl#[contractimpl]andmod testscargo build --target wasm32v1-nonewithout deprecation warningscargo testpasses; unit tests assert typed eventseventParser.jsdecodes typed topic + named data Mapevents.rsupdatedBehavioral Changes
MultisigApproved.countbug fix — the oldmultisig_approveevent emittedapprovals.len() + 1, over-counting by one.MultisigApproved { count }nowemits the correct post-approval
approvals.len().TipSentgainsledgerandmemo— the tip event now carries the ledgersequence and memo. (Previously
send_tipomitted memo/ledger, andbatch_send's per-recipient tip carried memo but no ledger.)MultisigCreatedgainssigners_countandexpiration_ledger— per theissue's migration map (both were already known at creation time).
Swapfields are all named —caller/token_in/token_outmoved fromtopics into the data Map alongside
amount_in/amount_out/fee.emitted, now with structured (and in several cases strictly richer) fields.
Consumer Impact & Breaking Changes
This is a breaking schema change for any consumer that reads raw event
topics/data directly:
live in the data Map (named keys) rather than a positional tuple.
tip→tip_sent,receipt→receipt_minted,escrow_create→escrow_created,escrow_claim→escrow_claimed,stream_open→stream_opened,stream_claim→stream_claimed,multisig_create→multisig_created,multisig_approve→multisig_approved.external indexer or generated client that consumed the legacy tuple events
must be regenerated/re-pointed (see Future Work).
Rollout Notes
soroban contract invoke --id <C> -- events(or RPCgetEvents) beforepointing production indexers at it.
STORAGE_LAYOUT_VERSIONis unchanged — this PR does not alter thepersistent
DataKeyenum or any stored struct layout, so no storage-migrationcompatibility bump is required. (Adding events changes the contract spec,
not the storage layout.)
contract-type-check.ymlbinding-drift check (gated onTESTNET_CONTRACT_ID,continue-on-error: true) will flag drift until thetyped-event SDK bindings are regenerated — tracked separately.
Future Work / Out of Scope
frontend/lib/contract-bindingsfor the new contract speccontract-type-check.ymlenv.register_contract→env.registerinMaliciousTokentestsyield_escrow#amm-integrationPR Checklist
env.events().publish()calls replaced with#[contractevent]structs#[allow(deprecated)]removed from the contract impl blockcargo build --target wasm32v1-nonecompiles with zero deprecation warningscargo testpasses (185 tests, 0 failures)events.rsupdated and documented