solana-wasi 0.1.0

Solana primitives that actually compile to wasm32-wasip2: pubkeys, PDAs, JSON-RPC over a swappable transport, SPL Token / Token-2022 account parsing, and unsigned v0 transaction construction. No solana-sdk, no C toolchain, no async runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
//! Token-2022 TLV extension parsing.
//!
//! This is where the interesting risk lives. A Token-2022 mint can carry a
//! permanent delegate that seizes any balance, a transfer hook that runs
//! arbitrary code on every transfer, a fee that the issuer can raise after you
//! agree to a price, or a default-frozen policy that makes the tokens you just
//! received unspendable. None of that is visible in the 82-byte base mint.
//!
//! Layout, from `spl-token-2022`: the base struct is padded to
//! [`BASE_ACCOUNT_LEN`] bytes, byte 165 is the account type discriminator, and
//! the TLV list starts at byte 166. Each entry is `u16` type, `u16` length,
//! then that many bytes of value, all little-endian.
//!
//! The parser is deliberately total: an unknown or truncated extension is
//! reported as [`MintExtension::Unknown`] / [`MintExtension::Malformed`] rather
//! than dropped, because "this mint carries an extension I do not understand"
//! is a finding, not a parse failure.

use crate::error::{Error, Result};
use crate::pubkey::Pubkey;

/// Every Token-2022 account is padded to at least this length before the
/// extension area begins.
pub const BASE_ACCOUNT_LEN: usize = 165;

/// Offset of the account-type discriminator.
pub const ACCOUNT_TYPE_OFFSET: usize = 165;

/// Offset of the first TLV entry.
pub const TLV_START: usize = 166;

/// A basis-point fee plus the epoch it becomes effective.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransferFee {
    /// Epoch from which this fee applies.
    pub epoch: u64,
    /// Absolute cap on the fee, in base units.
    pub maximum_fee: u64,
    /// Fee rate in basis points.
    pub basis_points: u16,
}

/// The state every new token account starts in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState {
    /// Allocated but not initialized.
    Uninitialized,
    /// Normal, spendable.
    Initialized,
    /// Cannot send or receive until thawed by the freeze authority.
    Frozen,
    /// A discriminator this crate does not know.
    Unknown(u8),
}

impl From<u8> for AccountState {
    fn from(b: u8) -> Self {
        match b {
            0 => AccountState::Uninitialized,
            1 => AccountState::Initialized,
            2 => AccountState::Frozen,
            other => AccountState::Unknown(other),
        }
    }
}

/// A parsed Token-2022 mint extension.
///
/// `Eq` is not derived: the scaled-UI-amount multiplier is an `f64`, and a NaN
/// multiplier is exactly the kind of value a hostile mint would write.
#[derive(Debug, Clone, PartialEq)]
pub enum MintExtension {
    /// A fee is withheld on every transfer. `config_authority` can change the
    /// fee, within the program's cap, at a future epoch.
    TransferFeeConfig {
        /// May raise the fee later.
        config_authority: Option<Pubkey>,
        /// May sweep the withheld balance.
        withdraw_withheld_authority: Option<Pubkey>,
        /// Fees withheld in the mint so far.
        withheld_amount: u64,
        /// The fee that applied before `newer.epoch`.
        older: TransferFee,
        /// The fee in effect now, or scheduled.
        newer: TransferFee,
    },
    /// Someone can close the mint account.
    MintCloseAuthority {
        /// The key allowed to close it.
        authority: Option<Pubkey>,
    },
    /// Balances can be encrypted; a configured auditor may decrypt them.
    ConfidentialTransferMint {
        /// May reconfigure confidential transfers.
        authority: Option<Pubkey>,
        /// New accounts are approved without issuer action.
        auto_approve_new_accounts: bool,
        /// An auditor that can decrypt every amount.
        auditor_elgamal_pubkey: Option<[u8; 32]>,
    },
    /// The state newly created accounts are initialized to. `Frozen` means a
    /// recipient cannot spend what you send them until the issuer thaws it.
    DefaultAccountState {
        /// The state new accounts start in.
        state: AccountState,
    },
    /// The token cannot be transferred at all.
    NonTransferable,
    /// Balances accrue interest, so the UI amount drifts from the raw amount.
    InterestBearingConfig {
        /// May change the rate.
        rate_authority: Option<Pubkey>,
        /// Current rate in basis points per year.
        current_rate_bps: i16,
    },
    /// A single address that can move tokens out of *any* account, forever,
    /// without the owner's signature.
    PermanentDelegate {
        /// The address holding that power.
        delegate: Option<Pubkey>,
    },
    /// Arbitrary program code runs on every transfer and can make it fail.
    TransferHook {
        /// May repoint the hook at a different program.
        authority: Option<Pubkey>,
        /// The program invoked on every transfer.
        program_id: Option<Pubkey>,
    },
    /// Where the token's metadata lives.
    MetadataPointer {
        /// May repoint it.
        authority: Option<Pubkey>,
        /// The account holding the metadata.
        metadata_address: Option<Pubkey>,
    },
    /// Metadata stored in the mint itself.
    ///
    /// `name`, `symbol` and `uri` are attacker-controlled strings. They are
    /// carried raw here; sanitize with [`crate::sanitize`] before they reach a
    /// log, a chat window, or a model's context.
    TokenMetadata {
        /// May rewrite every field below.
        update_authority: Option<Pubkey>,
        /// Attacker-controlled.
        name: String,
        /// Attacker-controlled.
        symbol: String,
        /// Attacker-controlled.
        uri: String,
        /// Keys of the additional-metadata map; values are not carried.
        additional_keys: Vec<String>,
    },
    /// Points at the group this token belongs to.
    GroupPointer {
        /// May repoint it.
        authority: Option<Pubkey>,
        /// The group account.
        address: Option<Pubkey>,
    },
    /// Points at this token's group membership record.
    GroupMemberPointer {
        /// May repoint it.
        authority: Option<Pubkey>,
        /// The membership account.
        address: Option<Pubkey>,
    },
    /// The UI amount is multiplied by a value the authority can change.
    ScaledUiAmountConfig {
        /// May change the multiplier.
        authority: Option<Pubkey>,
        /// The multiplier in effect.
        multiplier: f64,
        /// A multiplier scheduled to take effect.
        new_multiplier: f64,
    },
    /// All transfers can be halted by the authority.
    PausableConfig {
        /// May pause and resume transfers.
        authority: Option<Pubkey>,
        /// True when transfers are halted right now.
        paused: bool,
    },
    /// A known extension type this crate does not decode field-by-field.
    Known {
        /// TLV discriminator.
        kind: u16,
        /// Human-readable name.
        name: &'static str,
        /// Payload length in bytes.
        len: usize,
    },
    /// An extension type this crate has never heard of.
    Unknown {
        /// TLV discriminator.
        kind: u16,
        /// Payload length in bytes.
        len: usize,
    },
    /// A TLV entry whose declared length ran past the end of the account, or
    /// whose payload was too short for the layout its type implies.
    Malformed {
        /// TLV discriminator.
        kind: u16,
        /// The length the entry claimed.
        declared_len: usize,
    },
}

impl MintExtension {
    /// The TLV type discriminator this variant came from.
    pub fn kind(&self) -> u16 {
        match self {
            MintExtension::TransferFeeConfig { .. } => 1,
            MintExtension::MintCloseAuthority { .. } => 3,
            MintExtension::ConfidentialTransferMint { .. } => 4,
            MintExtension::DefaultAccountState { .. } => 6,
            MintExtension::NonTransferable => 9,
            MintExtension::InterestBearingConfig { .. } => 10,
            MintExtension::PermanentDelegate { .. } => 12,
            MintExtension::TransferHook { .. } => 14,
            MintExtension::MetadataPointer { .. } => 18,
            MintExtension::TokenMetadata { .. } => 19,
            MintExtension::GroupPointer { .. } => 20,
            MintExtension::GroupMemberPointer { .. } => 22,
            MintExtension::ScaledUiAmountConfig { .. } => 25,
            MintExtension::PausableConfig { .. } => 26,
            MintExtension::Known { kind, .. }
            | MintExtension::Unknown { kind, .. }
            | MintExtension::Malformed { kind, .. } => *kind,
        }
    }

    /// A short label for a summary line.
    pub fn label(&self) -> String {
        match self {
            MintExtension::TransferFeeConfig { .. } => "transfer fee".into(),
            MintExtension::MintCloseAuthority { .. } => "mint close authority".into(),
            MintExtension::ConfidentialTransferMint { .. } => "confidential transfers".into(),
            MintExtension::DefaultAccountState { .. } => "default account state".into(),
            MintExtension::NonTransferable => "non-transferable".into(),
            MintExtension::InterestBearingConfig { .. } => "interest bearing".into(),
            MintExtension::PermanentDelegate { .. } => "permanent delegate".into(),
            MintExtension::TransferHook { .. } => "transfer hook".into(),
            MintExtension::MetadataPointer { .. } => "metadata pointer".into(),
            MintExtension::TokenMetadata { .. } => "token metadata".into(),
            MintExtension::GroupPointer { .. } => "group pointer".into(),
            MintExtension::GroupMemberPointer { .. } => "group member pointer".into(),
            MintExtension::ScaledUiAmountConfig { .. } => "scaled ui amount".into(),
            MintExtension::PausableConfig { .. } => "pausable".into(),
            MintExtension::Known { name, .. } => (*name).to_string(),
            MintExtension::Unknown { kind, .. } => format!("unknown extension #{kind}"),
            MintExtension::Malformed { kind, .. } => format!("malformed extension #{kind}"),
        }
    }
}

/// Parse the extension area of a Token-2022 mint account.
///
/// `data` is the full account buffer. A buffer of exactly 82 bytes (a mint with
/// no extensions) yields an empty list, and so does any buffer too short to
/// hold a TLV header.
pub fn parse_mint_extensions(data: &[u8]) -> Result<Vec<MintExtension>> {
    if data.len() <= TLV_START {
        return Ok(Vec::new());
    }
    // 1 = Mint, 2 = Account. A mint buffer claiming to be an account is a
    // layout confusion we refuse rather than guess at.
    match data[ACCOUNT_TYPE_OFFSET] {
        1 => {}
        0 => return Ok(Vec::new()),
        other => {
            return Err(Error::InvalidAccountData(format!(
                "expected a token-2022 mint (account_type 1), found {other}"
            )))
        }
    }

    let mut out = Vec::new();
    let mut cursor = TLV_START;
    while cursor + 4 <= data.len() {
        let kind = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
        let len = u16::from_le_bytes([data[cursor + 2], data[cursor + 3]]) as usize;
        cursor += 4;

        // Type 0 with no payload is the zero padding after the last entry.
        if kind == 0 && len == 0 {
            break;
        }
        if cursor + len > data.len() {
            out.push(MintExtension::Malformed {
                kind,
                declared_len: len,
            });
            break;
        }
        let value = &data[cursor..cursor + len];
        cursor += len;
        out.push(decode(kind, value));

        // A pathological account cannot make us allocate without bound.
        if out.len() >= 64 {
            break;
        }
    }
    Ok(out)
}

fn decode(kind: u16, v: &[u8]) -> MintExtension {
    match kind {
        1 if v.len() >= 108 => MintExtension::TransferFeeConfig {
            config_authority: optional_pubkey(&v[0..32]),
            withdraw_withheld_authority: optional_pubkey(&v[32..64]),
            withheld_amount: u64_le(&v[64..72]),
            older: transfer_fee(&v[72..90]),
            newer: transfer_fee(&v[90..108]),
        },
        3 if v.len() >= 32 => MintExtension::MintCloseAuthority {
            authority: optional_pubkey(&v[0..32]),
        },
        4 if v.len() >= 65 => MintExtension::ConfidentialTransferMint {
            authority: optional_pubkey(&v[0..32]),
            auto_approve_new_accounts: v[32] != 0,
            auditor_elgamal_pubkey: {
                let mut b = [0u8; 32];
                b.copy_from_slice(&v[33..65]);
                if b == [0u8; 32] {
                    None
                } else {
                    Some(b)
                }
            },
        },
        6 if !v.is_empty() => MintExtension::DefaultAccountState {
            state: AccountState::from(v[0]),
        },
        9 => MintExtension::NonTransferable,
        10 if v.len() >= 52 => MintExtension::InterestBearingConfig {
            rate_authority: optional_pubkey(&v[0..32]),
            current_rate_bps: i16::from_le_bytes([v[50], v[51]]),
        },
        12 if v.len() >= 32 => MintExtension::PermanentDelegate {
            delegate: optional_pubkey(&v[0..32]),
        },
        14 if v.len() >= 64 => MintExtension::TransferHook {
            authority: optional_pubkey(&v[0..32]),
            program_id: optional_pubkey(&v[32..64]),
        },
        18 if v.len() >= 64 => MintExtension::MetadataPointer {
            authority: optional_pubkey(&v[0..32]),
            metadata_address: optional_pubkey(&v[32..64]),
        },
        19 => decode_token_metadata(v),
        20 if v.len() >= 64 => MintExtension::GroupPointer {
            authority: optional_pubkey(&v[0..32]),
            address: optional_pubkey(&v[32..64]),
        },
        22 if v.len() >= 64 => MintExtension::GroupMemberPointer {
            authority: optional_pubkey(&v[0..32]),
            address: optional_pubkey(&v[32..64]),
        },
        25 if v.len() >= 56 => MintExtension::ScaledUiAmountConfig {
            authority: optional_pubkey(&v[0..32]),
            multiplier: f64_le(&v[32..40]),
            new_multiplier: f64_le(&v[48..56]),
        },
        26 if v.len() >= 33 => MintExtension::PausableConfig {
            authority: optional_pubkey(&v[0..32]),
            paused: v[32] != 0,
        },
        // Known types with no security-relevant fields on a mint, or account-
        // side types that should never appear here.
        2 => known(kind, "transfer fee amount", v),
        5 => known(kind, "confidential transfer account", v),
        7 => known(kind, "immutable owner", v),
        8 => known(kind, "memo transfer", v),
        11 => known(kind, "cpi guard", v),
        13 => known(kind, "non-transferable account", v),
        15 => known(kind, "transfer hook account", v),
        16 => known(kind, "confidential transfer fee config", v),
        17 => known(kind, "confidential transfer fee amount", v),
        21 => known(kind, "token group", v),
        23 => known(kind, "token group member", v),
        24 => known(kind, "confidential mint/burn", v),
        27 => known(kind, "pausable account", v),
        // Right type, wrong size: report it, never guess at the fields.
        1 | 3 | 4 | 6 | 10 | 12 | 14 | 18 | 20 | 22 | 25 | 26 => MintExtension::Malformed {
            kind,
            declared_len: v.len(),
        },
        _ => MintExtension::Unknown {
            kind,
            len: v.len(),
        },
    }
}

fn known(kind: u16, name: &'static str, v: &[u8]) -> MintExtension {
    MintExtension::Known {
        kind,
        name,
        len: v.len(),
    }
}

/// `TokenMetadata` is the one variable-length extension, laid out as borsh
/// would encode it: a 32-byte optional authority, the mint, then three
/// length-prefixed UTF-8 strings and a key/value list.
fn decode_token_metadata(v: &[u8]) -> MintExtension {
    let mut c = 0usize;
    let update_authority = match take(v, &mut c, 32) {
        Some(b) => optional_pubkey(b),
        None => return MintExtension::Malformed { kind: 19, declared_len: v.len() },
    };
    if take(v, &mut c, 32).is_none() {
        return MintExtension::Malformed { kind: 19, declared_len: v.len() };
    }
    let name = match take_string(v, &mut c) {
        Some(s) => s,
        None => return MintExtension::Malformed { kind: 19, declared_len: v.len() },
    };
    let symbol = match take_string(v, &mut c) {
        Some(s) => s,
        None => return MintExtension::Malformed { kind: 19, declared_len: v.len() },
    };
    let uri = match take_string(v, &mut c) {
        Some(s) => s,
        None => return MintExtension::Malformed { kind: 19, declared_len: v.len() },
    };

    // Additional key/value pairs: read the keys only. The values are unbounded
    // attacker-controlled text and nothing downstream needs them.
    let mut additional_keys = Vec::new();
    if let Some(count_bytes) = take(v, &mut c, 4) {
        let count = u32::from_le_bytes([
            count_bytes[0],
            count_bytes[1],
            count_bytes[2],
            count_bytes[3],
        ]);
        for _ in 0..count.min(16) {
            match (take_string(v, &mut c), take_string(v, &mut c)) {
                (Some(k), Some(_)) => additional_keys.push(k),
                _ => break,
            }
        }
    }

    MintExtension::TokenMetadata {
        update_authority,
        name,
        symbol,
        uri,
        additional_keys,
    }
}

fn take<'a>(v: &'a [u8], cursor: &mut usize, n: usize) -> Option<&'a [u8]> {
    let end = cursor.checked_add(n)?;
    if end > v.len() {
        return None;
    }
    let out = &v[*cursor..end];
    *cursor = end;
    Some(out)
}

fn take_string(v: &[u8], cursor: &mut usize) -> Option<String> {
    let len_bytes = take(v, cursor, 4)?;
    let len = u32::from_le_bytes([len_bytes[0], len_bytes[1], len_bytes[2], len_bytes[3]]) as usize;
    // A 4-byte length prefix can claim 4GB. Bound it at the account size.
    if len > v.len() {
        return None;
    }
    let bytes = take(v, cursor, len)?;
    Some(String::from_utf8_lossy(bytes).into_owned())
}

/// Token-2022's `OptionalNonZeroPubkey`: all zero bytes means `None`.
fn optional_pubkey(b: &[u8]) -> Option<Pubkey> {
    if b.len() != 32 || b.iter().all(|x| *x == 0) {
        return None;
    }
    let mut arr = [0u8; 32];
    arr.copy_from_slice(b);
    Some(Pubkey::new_from_array(arr))
}

fn transfer_fee(b: &[u8]) -> TransferFee {
    TransferFee {
        epoch: u64_le(&b[0..8]),
        maximum_fee: u64_le(&b[8..16]),
        basis_points: u16::from_le_bytes([b[16], b[17]]),
    }
}

fn u64_le(b: &[u8]) -> u64 {
    let mut a = [0u8; 8];
    a.copy_from_slice(&b[..8]);
    u64::from_le_bytes(a)
}

fn f64_le(b: &[u8]) -> f64 {
    let mut a = [0u8; 8];
    a.copy_from_slice(&b[..8]);
    f64::from_le_bytes(a)
}