Thank you for your interest in contributing to StellarSplit! This repo is part of the Drips Wave Program — a monthly open-source bounty program run by the Stellar Development Foundation.
Do not begin coding until you have been assigned to an issue by a maintainer.
- Browse open issues and find one labelled
good first issueor matching your skill level. - Comment on the issue: "I'd like to work on this."
- Wait for a maintainer to assign you. Only then should you fork and start coding.
git clone https://github.com/<your-username>/split-contracts.git
cd split-contractsBranch names must follow this pattern:
fix/issue-NUMBER-short-description
feat/issue-NUMBER-short-description
Examples:
fix/issue-3-refund-edge-casefeat/issue-7-add-partial-release
git checkout -b fix/issue-42-short-description- Write clean, well-commented Rust code.
- Add or update tests in
contracts/split/src/test.rs. - Run
cargo test --workspaceand ensure all tests pass. - Run
cargo clippyand fix any warnings. - Run
cargo fmtto format your code.
Use conventional commits:
fix: correct refund logic when deadline is exact ledger timestamp (#42)
feat: add partial release function (#7)
- Title: concise, under 70 characters.
- Description: what changed, why, and how you tested it.
- Reference the issue:
Closes #42 - Do not open a PR without a linked issue.
- All public functions must have Rust doc comments (
///). - No
unwrap()in production code paths — useexpect("descriptive message")or proper error handling. - Keep functions small and focused.
ContractError (contracts/split/src/error.rs) is a #[repr(u32)] enum with an explicit
discriminant on every variant. Its doc comment states the rule: discriminants are stable —
never reorder, only append. Soroban clients and indexers match on the numeric error code, so
changing an existing variant's number (or reusing a retired one) is a breaking change even
though the Rust source still compiles.
When you need a new error case:
- Never reorder or renumber existing variants. Do not "tidy up" the list, fill gaps, or
resequence numbers to keep them contiguous — gaps (e.g.
50,52with51used elsewhere) are expected and are not bugs to fix. - Append your variant at the end of the enum, with the next unused discriminant. Find the current highest number in the file and add one to it — do not reuse a number that is skipped earlier in the list.
- Document it. Add a
///doc comment above the variant explaining when it is returned, and reference the issue number that introduced it (the existing variants follow an/// Issue #NNN: ...convention). - Update any call sites that need to return the new error, and add/extend tests in
contracts/split/src/test.rscovering the new failure path.
/// Issue #522: Parent chain depth exceeds the allowed maximum.
ParentChainTooDeep = 63,
} /// Issue #522: Parent chain depth exceeds the allowed maximum.
ParentChainTooDeep = 63,
/// Issue #611: Payout schedule references a milestone that does not exist.
MilestoneNotFound = 64,
}Note that the new variant is appended after the last existing one with the next free
discriminant (64); none of the earlier numbers are touched.
create_invoice takes its optional parameters through two parameter groups,
InvoiceOptions and
InvoiceOptions2, instead of a flat argument
list, so the function stays within Soroban's 10-parameter limit. Those two
structs are also the public surface that callers fill in, but the values that
must survive on chain are fanned out into the persistent invoice structs
(InvoiceCore,
InvoiceExt,
InvoiceExt2, and
InvoiceExt3).
Soroban #[contracttype] structs are capped at 40 fields. InvoiceOptions
is intentionally kept at (or very near) that ceiling, and the overflow bucket
InvoiceOptions2 exists so newly-added options do not break the limit.
- Put a new option in
InvoiceOptionsonly if it currently has fewer than 40 fields. - If
InvoiceOptionsis already at 40 fields, add the field toInvoiceOptions2instead (and point the doc comment at the issue, e.g./// Issue #NNN: ...). Never reorder or delete existing fields to "make room" — field order and offsets are part of the on-chain XDR layout.
Persistent invoice state is sharded on purpose so that hot-path reads stay small and so that new fields can be added without disturbing the core layout:
| Struct | Holds | When to add your field here |
|---|---|---|
InvoiceCore |
Always-present, frequently-read invoice facts (creator, recipients, amounts, status, funding). | Only for data every invoice carries and that the hot path reads. Rarely the right place for an optional new option. |
InvoiceExt |
The bulk of optional/extension fields (co-signers, penalties, tax, routing, velocity, etc.). | The default home for a new optional behavior flag or value. |
InvoiceExt2 |
Overflow extension state (notifications, disputes, auctions, oracle pricing, KYC, escrow). | When InvoiceExt is near its ceiling, or the field is logically grouped with dispute/auction/oracle state. |
InvoiceExt3 |
Newer extension bucket for recently added fields. | When both InvoiceExt and InvoiceExt2 are full. |
Rule of thumb: an option that is optional and only used by some invoices
belongs in InvoiceExt/InvoiceExt2/InvoiceExt3, not InvoiceCore.
- Add the field to the input struct. Decide
InvoiceOptionsvsInvoiceOptions2using the 40-field rule above. Add a/// Issue #NNN:doc comment describing the field. - Add the matching persisted field to the correct storage struct
(
InvoiceExt,InvoiceExt2, orInvoiceExt3) so the value is actually stored on chain. Keep the field name consistent with the input struct. - Wire the copy in
create_invoice. Find where the otherInvoiceOptionsfields are mapped intoInvoiceExt/InvoiceExt2and add the assignment (e.g.ext.my_field = options.my_field;). Also updateInvoiceExt::default(and any other default constructors) so the new field is initialised to its zero/empty/Nonedefault and is never accidentally omitted. - Thread it through reads/updates. If the field can change after creation
(e.g. via an
update_*orset_*entry point), update the corresponding getter/setter and any merge logic so the new value is round-tripped. - Update
STORAGE_KEY_REGISTRY.mdif your change introduces a new storage key (most option fields reuse the existing per-invoice key, so this is only needed for genuinely new keys). - Storage schema change → migration entry. Because you changed the shape
of an on-chain
#[contracttype]struct, this is a storage schema change. BumpCURRENT_SCHEMA_VERSIONinmigrations.rs, add amigration_vNfunction that backfills a sensible default for every invoice already stored on chain, and wire it intorun_pending_migrations(see the existingv1 -> v2/v2 -> v3examples in that file). Add a migration note to your PR description. Skipping this step leaves already-deployed contracts on a stale schema, and every entry point will panic withMigrationRequireduntilmigrateis called. - Tests. Add/extend tests in
contracts/split/src/test.rscovering: the field is accepted at creation, persisted, round-trips through any update path, and that the schema migration backfills a correct default for pre-existing invoices. - Docs. If the behavior is user-visible, mention it in
README.mdand/or the relevantdocs/page.
// InvoiceOptions2 is at the 40-field ceiling, so a new flag goes here:
pub struct InvoiceOptions2 {
// ...existing fields...
/// Issue #416: SHA-256 hash of the required off-chain release preimage.
pub release_condition_hash: Option<BytesN<32>>,
}pub struct InvoiceOptions2 {
// ...existing fields...
/// Issue #416: SHA-256 hash of the required off-chain release preimage.
pub release_condition_hash: Option<BytesN<32>>,
/// Issue #703: opt-in flag enabling per-payer receipt minting on release.
pub mint_receipts: Option<bool>,
}The same field is then added to InvoiceExt2 (or InvoiceExt3), copied in
create_invoice, defaulted in InvoiceExt2::default, and covered by a schema
migration + tests.
Open a Discussion or ask in the issue thread.