Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4891bd4
docs(dashpay): DIP-13 invitations spec (reviewed + synced)
shumkov Jul 8, 2026
1d8b490
feat(platform-wallet): DIP-13 invitation link codec
shumkov Jul 8, 2026
c60b3e3
feat(platform-wallet): path-gated invitation voucher-key export
shumkov Jul 8, 2026
9e40ee8
feat(platform-wallet): invitation create + claim flows
shumkov Jul 8, 2026
5039946
fix(platform-wallet): fold invitation Rust-core review findings
shumkov Jul 8, 2026
6a730a9
test(qa): add DIP-13 invitation scenarios to the example-app QA contract
shumkov Jul 8, 2026
f59edf6
feat(platform-wallet): invitation create + claim FFI
shumkov Jul 8, 2026
55c9537
fix(platform-wallet): scrub the exported voucher scalar in create_inv…
shumkov Jul 8, 2026
4d39bdc
feat(swift-sdk): createInvitation / claimInvitation wrappers on Manag…
shumkov Jul 8, 2026
2b2a871
feat(platform-wallet): persist inviter-side invitation records (Rust …
shumkov Jul 8, 2026
8fd0613
feat(swift-sdk): add DashPay create-invitation UI (DIP-13)
shumkov Jul 8, 2026
bd60102
feat(platform-wallet-ffi): add parse-invitation preview FFI + Swift w…
shumkov Jul 8, 2026
5967661
feat(swift-sdk): add DashPay claim-invitation flow (DIP-13)
shumkov Jul 8, 2026
4e0dfbe
feat(swift-sdk): handle dashpay:// invitation deep links
shumkov Jul 8, 2026
01cd566
test(platform-wallet-storage): allow-list invitations.rs read-only re…
shumkov Jul 8, 2026
a50a806
fix(swift-sdk): present invitation claim sheet on cold-launch deep link
shumkov Jul 8, 2026
0b51001
fix(rs-sdk-ffi): enforce fully-hardened invitation export path
shumkov Jul 8, 2026
2d568b7
fix(platform-wallet): fold invitation review findings
shumkov Jul 8, 2026
c2ddfcf
docs(dip15): sync spec §4.1/§6 with as-built code + note scheme limit…
shumkov Jul 8, 2026
a001d26
fix(swift-sdk): re-present invitation claim sheet on a second deep link
shumkov Jul 8, 2026
10fbc57
fix(platform-wallet): persist asset-lock account used-index across re…
shumkov Jul 10, 2026
74675fc
test(platform-wallet): pin asset-lock funding-index persistence
shumkov Jul 10, 2026
55937e1
fix(platform-wallet): gate invitation broadcast on funding-index persist
shumkov Jul 10, 2026
096e52d
feat(platform-wallet-ffi): topup-from-existing-asset-lock FFI (invita…
shumkov Jul 10, 2026
ce6a04d
feat(platform-wallet-ffi): invitation persistence callback (Sent-invi…
shumkov Jul 10, 2026
4e49286
test(platform-wallet-ffi): pin InvitationEntryFFI ABI size (layout gu…
shumkov Jul 10, 2026
9a6188c
docs(dip15): reviewed Sent-invitations persistence + reclaim spec
shumkov Jul 10, 2026
c1f0c45
feat(swift-sdk): Sent-invitations SwiftData bridge + list (DIP-13)
shumkov Jul 10, 2026
0b12d2d
fix(rs-sdk-ffi): revert over-strict hardened invitation export gate
shumkov Jul 10, 2026
3f498ba
fix(platform-wallet): persist invitation record for non-hardened fund…
shumkov Jul 10, 2026
7b371fe
fix(SwiftExampleApp): add PersistentInvitation to Storage Explorer views
shumkov Jul 10, 2026
4e3a9b0
style(platform-wallet-ffi): sort invitation_persistence re-export
shumkov Jul 10, 2026
30ab23f
Merge branch 'v4.1-dev' into feat/dip15-dashpay-invitations
shumkov Jul 10, 2026
d7ebdaa
feat(swift-sdk): reclaim an unclaimed DashPay invitation as credits
shumkov Jul 10, 2026
3029ffe
feat(platform-wallet): enforce a minimum invitation amount
shumkov Jul 10, 2026
261e2f2
fix(swift-sdk): raise invitation default above the claimable floor
shumkov Jul 10, 2026
d14c02e
fix(swift-sdk): narrow already-consumed classifier to canonical Displ…
shumkov Jul 10, 2026
139a478
fix(swift-sdk): disable Cancel during an in-flight invitation reclaim
shumkov Jul 10, 2026
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
616 changes: 616 additions & 0 deletions docs/dashpay/DIP15_INVITATIONS_SPEC.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/rs-platform-wallet-ffi/src/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,20 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider {
.map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()))
}

async fn export_invitation_private_key(
&self,
path: &key_wallet::bip32::DerivationPath,
) -> Result<dashcore::secp256k1::SecretKey, platform_wallet::PlatformWalletError> {
let scalar = self
.signer
.export_invitation_private_key(path)
.map_err(|e| {
platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())
})?;
dashcore::secp256k1::SecretKey::from_slice(scalar.as_ref())
.map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()))
}

async fn account_reference(
&self,
path: &key_wallet::bip32::DerivationPath,
Expand Down
610 changes: 610 additions & 0 deletions packages/rs-platform-wallet-ffi/src/invitation.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/rs-platform-wallet-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub mod identity_top_up;
pub mod identity_transfer;
pub mod identity_update;
pub mod identity_withdrawal;
pub mod invitation;
pub mod logging;
pub mod managed_identity;
pub mod manager;
Expand Down Expand Up @@ -115,6 +116,7 @@ pub use identity_top_up::*;
pub use identity_transfer::*;
pub use identity_update::*;
pub use identity_withdrawal::*;
pub use invitation::*;
pub use logging::*;
pub use managed_identity::*;
pub use manager::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Add the `invitations` table (DIP-13 DashPay invitations).
//!
//! Inviter-side records of created invitations, powering the "Sent invitations"
//! status list and (future) reclaim of an unclaimed voucher. **No key material
//! is stored** — the one-time voucher key is HD-derived and re-derivable from
//! `funding_index` on demand.
//!
//! All fields map to explicit columns (the entry is all-primitive), so no
//! opaque lifecycle blob is needed — the row reconstructs directly.

pub fn migration() -> String {
"CREATE TABLE invitations (
wallet_id BLOB NOT NULL,
outpoint BLOB NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created', 'claimed', 'reclaimed')),
funding_index INTEGER NOT NULL,
amount_duffs INTEGER NOT NULL,
expiry_unix INTEGER NOT NULL,
created_at_secs INTEGER NOT NULL,
has_inviter INTEGER NOT NULL,
PRIMARY KEY (wallet_id, outpoint),
FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
);"
.to_string()
}
3 changes: 3 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,9 @@ fn apply_changeset_to_tx(
if let Some(locks) = cs.asset_locks.as_ref() {
schema::asset_locks::apply(tx, wallet_id, locks)?;
}
if let Some(invitations) = cs.invitations.as_ref() {
schema::invitations::apply(tx, wallet_id, invitations)?;
}
if let Some(balances) = cs.token_balances.as_ref() {
schema::token_balances::apply(tx, wallet_id, balances)?;
}
Expand Down
192 changes: 192 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//! `invitations` table writer + reader (DIP-13 DashPay invitations).
//!
//! Every field maps to an explicit column (the entry is all-primitive), so a
//! row reconstructs an [`InvitationEntry`] directly — no lifecycle blob. No key
//! material is stored: the voucher key is re-derived from `funding_index`.

use rusqlite::{params, Transaction};

use platform_wallet::changeset::{InvitationChangeSet, InvitationStatus};
use platform_wallet::wallet::platform_wallet::WalletId;

use crate::sqlite::error::WalletStorageError;
use crate::sqlite::schema::blob;

// Imports used only by the test-gated reader below.
#[cfg(any(test, feature = "__test-helpers"))]
use {
dashcore::OutPoint, platform_wallet::changeset::InvitationEntry, rusqlite::Connection,
std::collections::BTreeMap,
};

pub fn apply(
tx: &Transaction<'_>,
wallet_id: &WalletId,
cs: &InvitationChangeSet,
) -> Result<(), WalletStorageError> {
if !cs.invitations.is_empty() {
let mut stmt = tx.prepare_cached(
"INSERT INTO invitations \
(wallet_id, outpoint, status, funding_index, amount_duffs, expiry_unix, created_at_secs, has_inviter) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \
status = excluded.status, \
funding_index = excluded.funding_index, \
amount_duffs = excluded.amount_duffs, \
expiry_unix = excluded.expiry_unix, \
created_at_secs = excluded.created_at_secs, \
has_inviter = excluded.has_inviter",
)?;
for (op, entry) in &cs.invitations {
let op_bytes = blob::encode_outpoint(op)?;
stmt.execute(params![
wallet_id.as_slice(),
&op_bytes[..],
status_str(&entry.status),
i64::from(entry.funding_index),
crate::sqlite::util::safe_cast::u64_to_i64(
"invitations.amount_duffs",
entry.amount_duffs,
)?,
i64::from(entry.expiry_unix),
i64::from(entry.created_at_secs),
i64::from(entry.has_inviter),
])?;
}
}
if !cs.removed.is_empty() {
let mut stmt =
tx.prepare_cached("DELETE FROM invitations WHERE wallet_id = ?1 AND outpoint = ?2")?;
for op in &cs.removed {
let op_bytes = blob::encode_outpoint(op)?;
stmt.execute(params![wallet_id.as_slice(), &op_bytes[..]])?;
}
}
Ok(())
}

/// Single source of truth for the `invitations.status` TEXT-column domain.
/// The `CHECK (status IN …)` in `migrations/V003__invitations.rs` must list
/// exactly these values.
pub(crate) fn status_str(s: &InvitationStatus) -> &'static str {
match s {
InvitationStatus::Created => "created",
InvitationStatus::Claimed => "claimed",
InvitationStatus::Reclaimed => "reclaimed",
}
}

#[cfg(any(test, feature = "__test-helpers"))]
fn status_from_str(s: &str) -> Result<InvitationStatus, WalletStorageError> {
match s {
"created" => Ok(InvitationStatus::Created),
"claimed" => Ok(InvitationStatus::Claimed),
"reclaimed" => Ok(InvitationStatus::Reclaimed),
_ => Err(WalletStorageError::blob_decode(
"unknown invitations.status value in row",
)),
}
}

/// Read every invitation row for a wallet, keyed by outpoint. Test/round-trip
/// helper (the production load path does not re-hydrate invitations into the
/// Rust manager; the Swift SwiftData mirror is the UI source).
#[cfg(any(test, feature = "__test-helpers"))]
pub fn read_all(
conn: &Connection,
wallet_id: &WalletId,
) -> Result<BTreeMap<OutPoint, InvitationEntry>, WalletStorageError> {
let mut stmt = conn.prepare(
"SELECT outpoint, status, funding_index, amount_duffs, expiry_unix, created_at_secs, has_inviter \
FROM invitations WHERE wallet_id = ?1",
)?;
let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
Ok((
row.get::<_, Vec<u8>>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i64>(6)?,
))
})?;
let mut out = BTreeMap::new();
for row in rows {
let (op_bytes, status, funding_index, amount, expiry, created_at, has_inviter) = row?;
let out_point = blob::decode_outpoint(&op_bytes)?;
out.insert(
out_point,
InvitationEntry {
out_point,
funding_index: funding_index as u32,
amount_duffs: amount as u64,
expiry_unix: expiry as u32,
created_at_secs: created_at as u32,
has_inviter: has_inviter != 0,
status: status_from_str(&status)?,
},
);
}
Ok(out)
}

#[cfg(test)]
mod tests {
use super::*;
use dashcore::hashes::Hash;

fn entry(vout: u32, status: InvitationStatus) -> InvitationEntry {
InvitationEntry {
out_point: OutPoint::new(dashcore::Txid::from_byte_array([vout as u8; 32]), vout),
funding_index: vout,
amount_duffs: 100_000 + u64::from(vout),
expiry_unix: 1_800_000_000,
created_at_secs: 1_799_913_600,
has_inviter: vout.is_multiple_of(2),
status,
}
}

#[test]
fn apply_then_read_round_trips_and_upserts_and_removes() {
let wallet_id: WalletId = [0x11; 32];
let mut conn = Connection::open_in_memory().unwrap();
crate::sqlite::migrations::run(&mut conn).unwrap();
// The FK requires the wallet_metadata row to exist.
conn.execute(
"INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)",
params![&wallet_id[..]],
)
.unwrap();

// Insert two.
let e0 = entry(0, InvitationStatus::Created);
let e1 = entry(1, InvitationStatus::Created);
let mut cs = InvitationChangeSet::default();
cs.invitations.insert(e0.out_point, e0.clone());
cs.invitations.insert(e1.out_point, e1.clone());
{
let tx = conn.transaction().unwrap();
apply(&tx, &wallet_id, &cs).unwrap();
tx.commit().unwrap();
}
let got = read_all(&conn, &wallet_id).unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got[&e0.out_point], e0);

// Upsert e0 → Claimed, remove e1.
let mut cs2 = InvitationChangeSet::default();
let e0b = entry(0, InvitationStatus::Claimed);
cs2.invitations.insert(e0b.out_point, e0b.clone());
cs2.removed.insert(e1.out_point);
{
let tx = conn.transaction().unwrap();
apply(&tx, &wallet_id, &cs2).unwrap();
tx.commit().unwrap();
}
let got = read_all(&conn, &wallet_id).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[&e0.out_point].status, InvitationStatus::Claimed);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod core_state;
pub mod dashpay;
pub mod identities;
pub mod identity_keys;
pub mod invitations;
pub mod pending_contact_crypto;
pub mod platform_addrs;
pub mod token_balances;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[
"pending_contact_crypto.rs",
"SELECT wallet_id, payload FROM pending_contact_crypto",
),
(
"invitations.rs",
"SELECT outpoint, status, funding_index, amount_duffs",
),
];

/// TC-P1-003: writer paths in `src/sqlite/schema/*.rs` must not call
Expand Down
69 changes: 69 additions & 0 deletions packages/rs-platform-wallet/src/changeset/changeset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,71 @@ impl Merge for AssetLockChangeSet {
}
}

// ---------------------------------------------------------------------------
// DashPay Invitations (DIP-13)
// ---------------------------------------------------------------------------

/// Lifecycle status of an inviter-side invitation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum InvitationStatus {
/// Created and shared; the funding asset lock is unspent.
Created,
/// The voucher was consumed — an identity was registered from it.
Claimed,
/// The inviter reclaimed the unspent voucher back into their wallet.
Reclaimed,
}

/// A single inviter-side invitation record (DIP-13).
///
/// **No secret is stored.** The one-time voucher private key is HD-derived and
/// re-derivable from `funding_index` on demand (for re-packaging or reclaiming an
/// unclaimed invitation); it is never persisted.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct InvitationEntry {
/// The funding asset lock's outpoint (this record's identity).
pub out_point: OutPoint,
/// DIP-13 invitation funding index (`m/9'/coin'/5'/3'/<funding_index>'`);
/// re-derives the voucher key.
pub funding_index: u32,
/// Amount locked in the voucher (duffs).
pub amount_duffs: u64,
/// Advisory expiry (unix seconds).
pub expiry_unix: u32,
/// Unix seconds when the invitation was created.
pub created_at_secs: u32,
/// Whether the inviter opted into the contact-bootstrap ("send a request
/// back to me").
pub has_inviter: bool,
/// Current lifecycle status.
pub status: InvitationStatus,
}

/// Inviter-side invitation records emitted by `create_invitation` (and, later,
/// reclaim + a status sync that flips `Created → Claimed`).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct InvitationChangeSet {
/// Invitation records keyed by funding outpoint. Last write wins on merge.
pub invitations: BTreeMap<OutPoint, InvitationEntry>,
/// Invitations removed from tracking.
pub removed: BTreeSet<OutPoint>,
}

impl Merge for InvitationChangeSet {
fn merge(&mut self, other: Self) {
// Last write wins — later status is higher finality.
self.invitations.extend(other.invitations);
self.removed.extend(other.removed);
}

fn is_empty(&self) -> bool {
self.invitations.is_empty() && self.removed.is_empty()
}
}

// ---------------------------------------------------------------------------
// Token Balances
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1157,6 +1222,8 @@ pub struct PlatformWalletChangeSet {
pub platform_addresses: Option<PlatformAddressChangeSet>,
/// Asset lock lifecycle changes (created, locked, used).
pub asset_locks: Option<AssetLockChangeSet>,
/// DashPay invitation (DIP-13) records — inviter-side create/reclaim.
pub invitations: Option<InvitationChangeSet>,
/// Platform token balance / watch changes.
pub token_balances: Option<TokenBalanceChangeSet>,
/// DashPay profile overlays keyed by identity ID. Applied AFTER
Expand Down Expand Up @@ -1263,6 +1330,7 @@ impl Merge for PlatformWalletChangeSet {
self.contacts.merge(other.contacts);
self.platform_addresses.merge(other.platform_addresses);
self.asset_locks.merge(other.asset_locks);
self.invitations.merge(other.invitations);
self.token_balances.merge(other.token_balances);
// DashPay overlays: LWW per identity_id.
if let Some(other_profiles) = other.dashpay_profiles {
Expand Down Expand Up @@ -1312,6 +1380,7 @@ impl Merge for PlatformWalletChangeSet {
&& self.contacts.is_empty()
&& self.platform_addresses.is_empty()
&& self.asset_locks.is_empty()
&& self.invitations.is_empty()
&& self.token_balances.is_empty()
&& self.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty())
&& self
Expand Down
9 changes: 5 additions & 4 deletions packages/rs-platform-wallet/src/changeset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ pub use changeset::{
upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry,
AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet,
IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry,
IdentityKeysChangeSet, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto,
PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp,
PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet,
ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry,
IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus,
KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey,
PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry,
PlatformAddressChangeSet, PlatformWalletChangeSet, ReceivedContactRequestKey,
SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry,
};
pub use client_start_state::ClientStartState;
pub use client_wallet_start_state::ClientWalletStartState;
Expand Down
Loading
Loading