Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a80f9d2
fix(swift-sdk): correct token balance and decimal-amount handling on …
llbartekll May 4, 2026
0ea3b4c
fix(swift-sdk): decimal-aware amounts on Mint/Purchase/UpdateMaxSuppl…
llbartekll May 4, 2026
87a5886
fix(swift-sdk): use canonical token id when fetching balance from Per…
llbartekll May 4, 2026
23831df
fix(swift-sdk): tighten token-amount parser, scope balance to current…
llbartekll May 4, 2026
d96d9d4
fix(swift-sdk): Mint-to-self toggle passes caller identity instead of…
llbartekll May 5, 2026
c825556
fix(swift-sdk): truth-in-UI pass on token action resolver, mint label…
llbartekll May 5, 2026
69c4e8b
fix(swift-sdk): generation guards on token-action submit + balance fe…
llbartekll May 5, 2026
35c0929
remove dead code
llbartekll May 5, 2026
266a2fa
feat(swift-sdk): copyable identity ID on Identity Details
llbartekll May 5, 2026
608a2fe
fix(swift-sdk): re-key contract `groups` array back to position-keyed…
llbartekll May 6, 2026
7c0c8c7
feat(swift-sdk): surface base58 identity ID alongside hex on Identity…
llbartekll May 6, 2026
aa9570b
fix(platform-wallet): drop mint recipient when contract forbids choos…
llbartekll May 6, 2026
238f5e4
fix(swift-sdk): reconcile token isPaused with chain on Token Actions
llbartekll May 6, 2026
dda584d
fix(swift-sdk): flip token maxSupply locally after successful UpdateM…
llbartekll May 6, 2026
7380942
chore(swift-sdk): drop trailing blank line at EOF in Burn/Transfer ac…
llbartekll May 6, 2026
a9aae73
chore(swift-sdk): address CodeRabbit feedback on #3604
llbartekll May 6, 2026
1a468a6
chore: address thepastaclaw review on #3604
llbartekll May 6, 2026
e551334
fix(swift-sdk): preserve seeded token balance until identity changes
llbartekll May 6, 2026
835fdd9
fix(swift-sdk): accept canonical groups map shape in transition builder
llbartekll May 6, 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
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
//! Token mint — issues `amount` of `(contract, token_position)`.
//! `recipient_id == None` mints to `identity_id`. Group-gateable.
//! `recipient_id == None` defers to the contract's
//! `newTokensDestinationIdentity`. When the contract sets
//! `minting_allow_choosing_destination = false`, this helper drops any
//! non-`None` `recipient_id` to keep co-sign replays from tripping the
//! chain-side `ChoosingTokenMintRecipientNotAllowed` validator.
//! Group-gateable.

use std::sync::Arc;

use dpp::balances::credits::TokenAmount;
use dpp::data_contract::accessors::v1::DataContractV1Getters;
use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters;
use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters;
use dpp::data_contract::{DataContract, TokenContractPosition};
use dpp::identity::signer::Signer;
use dpp::identity::IdentityPublicKey;
Expand Down Expand Up @@ -33,6 +41,45 @@ impl<B: TransactionBroadcaster + ?Sized> IdentityWallet<B> {
) -> Result<dash_sdk::platform::tokens::transitions::MintResult, dash_sdk::Error> {
use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder;

// Contracts with `minting_allow_choosing_destination = false`
// reject any mint where `issued_to_identity_id.is_some()` at
// execution time (rs-drive's TokenMintTransitionTransformer
// rule). The proposer-mode submission is stored as a pending
// action and bypasses this check, but the chain stores
// `TokenEvent::Mint` with the resolved
// `newTokensDestinationIdentity` baked in. The co-sign path
// then surfaces that resolved id back to the caller as a
// non-optional recipient and replays it, tripping the
// validator. Normalize here so neither caller has to think
// about it: when the rule forbids choosing, drop the
// recipient and let the chain resolve to
// `newTokensDestinationIdentity` (or surface
// `DestinationIdentityForTokenMintingNotSet` if that's also
// unset).
let recipient_id = if !data_contract
.expected_token_configuration(token_position)
.map_err(dash_sdk::Error::Protocol)?
.distribution_rules()
.minting_allow_choosing_destination()
{
// Surface the silent override in logs so downstream
// debugging doesn't have to reverse-engineer "the helper
// ignored my recipient" from a successful mint to
// `newTokensDestinationIdentity`. Only emit when we
// actually changed something — a pre-existing `None`
// is a no-op.
if let Some(supplied) = recipient_id.as_ref() {
tracing::debug!(
supplied = %bs58::encode(supplied.to_buffer()).into_string(),
token_position,
"token_mint normalizing caller-supplied recipient_id to None: contract forbids choosing destination"
);
}
None
} else {
recipient_id
};
Comment on lines +59 to +81
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Silent recipient override deserves a tracing breadcrumb

The fix is correct for the co-sign replay path. But the helper is generic — token_mint_with_external_signer and any other in-tree caller now silently overrides a caller-supplied recipient_id whenever the contract sets minting_allow_choosing_destination = false. Before this change, such a call would propagate a chain rejection (ChoosingTokenMintRecipientNotAllowed); after, it succeeds and mints to newTokensDestinationIdentity instead, with no error. The doc comment documents this, but a tracing::debug! (or warn! when the dropped value differs from the configured destination) would make the override observable in logs without changing semantics. Not blocking.

source: ['claude']


let builder =
TokenMintTransitionBuilder::new(data_contract, token_position, identity_id, amount);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Foundation

/// Conversions between a token's *display unit* (what the user types
/// and reads — e.g. `4.44`) and its *raw on-chain unit* (what the FFI
/// expects — `UInt64`, scaled by `decimals`). Without these, a token
/// with 8 decimals and a balance of `444667781` raw units would render
/// as `4.44667781` while a typed amount of `5` would be sent through
/// as `5` raw units (`0.00000005`), silently sneaking past every
/// `amount <= balance` check.
///
/// Both helpers use `Decimal` (not `Double`) so high-decimal tokens
/// with large balances don't lose precision.

/// Parse a user-entered amount in display units into raw on-chain
/// units by multiplying by `10^decimals`. Accepts both "." and ","
/// as the decimal separator so users in either locale can type
/// naturally. Returns `nil` for empty / unparseable / negative /
/// out-of-`UInt64`-range input.
///
/// The grammar is deliberately strict: digits, optionally followed by
/// a single separator and more digits — *no* thousands separators, no
/// trailing junk, no scientific notation. `Decimal(string:)` on its
/// own happily accepts a valid prefix and silently drops the rest, so
/// `"5abc"` would parse as `5` and a pasted `"1,234.56"` (after the
/// `,` → `.` normalization) would become `"1.234.56"` and parse as
/// `1.234`. Either of those would let the user submit a materially
/// different raw amount than what they think they typed.
///
/// Excess fractional digits beyond `decimals` are truncated (rounded
/// down). Silently rounding *up* would let the user submit slightly
/// more than they typed, which would surprise on a balance edge.
func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

let normalized = trimmed.replacingOccurrences(of: ",", with: ".")

// Strict grammar: `\d+(\.\d+)?` or `\.\d+` (a leading-dot like
// ".5" is what `.decimalPad` actually emits in some locales).
guard isWellFormedDecimal(normalized) else { return nil }

guard let entered = Decimal(string: normalized) else { return nil }
// `isWellFormedDecimal` accepts only digits + a single `.`, so
// `Decimal(string:)` cannot return a negative value here. The
// previous `entered >= 0` and `rounded < 0` guards next to this
// site were dead code; only the `> UInt64.max` overflow check is
// reachable (large valid-shape input scaled by `10^decimals`).

let dec = max(0, decimals)
let multiplier = pow(Decimal(10), dec)
var scaled = entered * multiplier
var rounded = Decimal()
NSDecimalRound(&rounded, &scaled, 0, .down)

if rounded > Decimal(UInt64.max) { return nil }
return (rounded as NSDecimalNumber).uint64Value
}

private func isWellFormedDecimal(_ s: String) -> Bool {
var sawDigit = false
var sawDot = false
for ch in s {
if ch.isASCII, let ascii = ch.asciiValue, ascii >= 0x30 && ascii <= 0x39 {
sawDigit = true
} else if ch == "." {
if sawDot { return false }
sawDot = true
} else {
return false
}
}
return sawDigit
}

/// Format a raw u64 token amount with the given decimals. Honors the
/// current locale's *decimal* separator (European users see
/// `1234,56`, US users see `1234.56`) but **does not** insert a
/// thousands grouping separator. Round-trip safety: `parseTokenAmount`
/// strict grammar + `,→.` normalization happily parses `"1,234"` as
/// `1.234` (a thousand times smaller than the user thought), so a user
/// copying any portion of a grouped display value into a Mint / Burn /
/// Transfer amount field could silently lose three orders of
/// magnitude. Disabling grouping in this formatter makes every value
/// it produces a valid input for the parser.
func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String {
let dec = max(0, decimals)
let rawDecimal = Decimal(raw)
// `dec` is clamped to `>= 0` above, so `pow(Decimal(10), dec)` is
// always >= 1 — the previous `divisor == 0 ? rawDecimal : …` guard
// was unreachable and only obscured the scaling intent.
let divisor = pow(Decimal(10), dec)
let scaled = rawDecimal / divisor
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = dec
formatter.minimumFractionDigits = 0
// Intentionally off — see the function-level doc comment.
formatter.usesGroupingSeparator = false
return formatter.string(from: scaled as NSNumber) ?? "\(raw)"
}
Comment on lines +85 to +100
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: formatTokenAmount + parseTokenAmount are not round-trip safe under grouping separators

formatTokenAmount sets usesGroupingSeparator = true, so a US-locale balance renders as e.g. "1,234.56789012". If a user copies any portion of that display value into one of the action views' Amount field, parseTokenAmount first does "," -> "." normalization before the strict grammar check. The strict grammar correctly rejects "1,234.56" (becomes "1.234.56"), but "1,234" (becomes "1.234") is well-formed and parses as 1.234 — i.e. ~1000× smaller than what the user copied. The fix is asymmetric: either disable grouping in formatTokenAmount (formatter.usesGroupingSeparator = false) so the displayed value is also a valid input, or strip grouping separators before normalization in parseTokenAmount.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift`:
- [SUGGESTION] lines 77-91: formatTokenAmount + parseTokenAmount are not round-trip safe under grouping separators
  `formatTokenAmount` sets `usesGroupingSeparator = true`, so a US-locale balance renders as e.g. `"1,234.56789012"`. If a user copies any portion of that display value into one of the action views' Amount field, `parseTokenAmount` first does `"," -> "."` normalization before the strict grammar check. The strict grammar correctly rejects `"1,234.56"` (becomes `"1.234.56"`), but `"1,234"` (becomes `"1.234"`) is well-formed and parses as `1.234` — i.e. ~1000× smaller than what the user copied. The fix is asymmetric: either disable grouping in `formatTokenAmount` (`formatter.usesGroupingSeparator = false`) so the displayed value is also a valid input, or strip grouping separators before normalization in `parseTokenAmount`.

Loading
Loading