Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,110 @@ When you need a new error case:
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.

## Adding a new invoice option field

`create_invoice` takes its optional parameters through two parameter groups,
[`InvoiceOptions`](contracts/split/src/types.rs) and
[`InvoiceOptions2`](contracts/split/src/types.rs), 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`](contracts/split/src/types.rs),
[`InvoiceExt`](contracts/split/src/types.rs),
[`InvoiceExt2`](contracts/split/src/types.rs), and
[`InvoiceExt3`](contracts/split/src/types.rs)).

### The 40-field `#[contracttype]` constraint

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 **`InvoiceOptions`** only if it currently has fewer than
40 fields.
- If `InvoiceOptions` is already at 40 fields, add the field to
**`InvoiceOptions2`** instead (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.

### The `InvoiceCore` / `InvoiceExt` / `InvoiceExt2` storage split

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`.

### End-to-end checklist for adding a new field

1. **Add the field to the input struct.** Decide `InvoiceOptions` vs
`InvoiceOptions2` using the 40-field rule above. Add a `/// Issue #NNN:`
doc comment describing the field.
2. **Add the matching persisted field** to the correct storage struct
(`InvoiceExt`, `InvoiceExt2`, or `InvoiceExt3`) so the value is actually
stored on chain. Keep the field name consistent with the input struct.
3. **Wire the copy in `create_invoice`.** Find where the other `InvoiceOptions`
fields are mapped into `InvoiceExt`/`InvoiceExt2` and add the assignment
(e.g. `ext.my_field = options.my_field;`). Also update `InvoiceExt::default`
(and any other default constructors) so the new field is initialised to its
zero/empty/`None` default and is never accidentally omitted.
4. **Thread it through reads/updates.** If the field can change after creation
(e.g. via an `update_*` or `set_*` entry point), update the corresponding
getter/setter and any merge logic so the new value is round-tripped.
5. **Update `STORAGE_KEY_REGISTRY.md`** if 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).
6. **Storage schema change → migration entry.** Because you changed the shape
of an on-chain `#[contracttype]` struct, this is a **storage schema change**.
Bump `CURRENT_SCHEMA_VERSION` in
[`migrations.rs`](contracts/split/src/migrations.rs), add a `migration_vN`
function that backfills a sensible default for every invoice already stored
on chain, and wire it into `run_pending_migrations` (see the existing
`v1 -> v2` / `v2 -> v3` examples 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 with `MigrationRequired`
until `migrate` is called.
7. **Tests.** Add/extend tests in `contracts/split/src/test.rs` covering: 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.
8. **Docs.** If the behavior is user-visible, mention it in `README.md` and/or
the relevant `docs/` page.

### Before

```rust
// 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>>,
}
```

### After

```rust
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.

## Questions?

Open a [Discussion](../../discussions) or ask in the issue thread.