diff --git a/modules/Sources/Features/TransactionDetails/TransactionDetailsStore.swift b/modules/Sources/Features/TransactionDetails/TransactionDetailsStore.swift index 92d8797c2..e2a2abbdd 100644 --- a/modules/Sources/Features/TransactionDetails/TransactionDetailsStore.swift +++ b/modules/Sources/Features/TransactionDetails/TransactionDetailsStore.swift @@ -431,7 +431,7 @@ public struct TransactionDetails { case .memosLoaded(let memos): state.areMessagesResolved = true state.$transactionMemos.withLock { - $0[state.transaction.id] = memos.compactMap { $0.toString() } + $0[state.transaction.id] = memos.compactMap { $0.toString() }.map { Zap1Attestation.format($0) ?? $0 } } state.messageStates = state.memos.map { $0.count < State.Constants.messageExpandThreshold ? .short : .longCollapsed diff --git a/modules/Sources/Features/TransactionDetails/Zap1MemoParser.swift b/modules/Sources/Features/TransactionDetails/Zap1MemoParser.swift new file mode 100644 index 000000000..7f2c6162a --- /dev/null +++ b/modules/Sources/Features/TransactionDetails/Zap1MemoParser.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Parses ZAP1 and legacy NSM1 attestation memos into structured data. +struct Zap1Attestation { + let prefix: String + let typeHex: String + let event: String + let hash: String + + var shortHash: String { String(hash.prefix(12)) + "..." } + var isLegacy: Bool { prefix == "NSM1" } + + // Compiled once, reused across all parse calls + private static let pattern = try! NSRegularExpression( + pattern: #"^(ZAP1|NSM1):([0-9a-fA-F]{2}):([0-9a-fA-F]{64})$"# + ) + + private static let events: [String: String] = [ + "01": "PROGRAM_ENTRY", "02": "OWNERSHIP_ATTEST", + "03": "CONTRACT_ANCHOR", "04": "DEPLOYMENT", + "05": "HOSTING_PAYMENT", "06": "SHIELD_RENEWAL", + "07": "TRANSFER", "08": "EXIT", + "09": "MERKLE_ROOT", "0a": "STAKING_DEPOSIT", + "0b": "STAKING_WITHDRAW", "0c": "STAKING_REWARD", + "0d": "GOVERNANCE_PROPOSAL", "0e": "GOVERNANCE_VOTE", + "0f": "GOVERNANCE_RESULT" + ] + + static func parse(_ memo: String) -> Zap1Attestation? { + let trimmed = memo.trimmingCharacters(in: .whitespacesAndNewlines) + let range = NSRange(trimmed.startIndex..., in: trimmed) + guard let match = pattern.firstMatch(in: trimmed, range: range), + let prefixRange = Range(match.range(at: 1), in: trimmed), + let typeRange = Range(match.range(at: 2), in: trimmed), + let hashRange = Range(match.range(at: 3), in: trimmed) + else { return nil } + + let prefix = String(trimmed[prefixRange]) + let typeHex = String(trimmed[typeRange]).lowercased() + let hash = String(trimmed[hashRange]) + + return Zap1Attestation( + prefix: prefix, + typeHex: typeHex, + event: events[typeHex] ?? "TYPE_0x\(typeHex)", + hash: hash + ) + } + + static func format(_ memo: String) -> String? { + guard let attestation = parse(memo) else { return nil } + return "ZAP1: \(attestation.event) \(attestation.shortHash)" + } +} diff --git a/secantTests/Zap1MemoParserTests.swift b/secantTests/Zap1MemoParserTests.swift new file mode 100644 index 000000000..f3c818fcd --- /dev/null +++ b/secantTests/Zap1MemoParserTests.swift @@ -0,0 +1,68 @@ +import XCTest + +class Zap1MemoParserTests: XCTestCase { + func testParseProgramEntry() { + let att = Zap1Attestation.parse( + "ZAP1:01:075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" + ) + + XCTAssertNotNil(att) + XCTAssertEqual(att?.prefix, "ZAP1") + XCTAssertEqual(att?.typeHex, "01") + XCTAssertEqual(att?.event, "PROGRAM_ENTRY") + XCTAssertEqual(att?.shortHash, "075b00df2860...") + XCTAssertEqual(att?.isLegacy, false) + } + + func testParseLegacyNSM1() { + let att = Zap1Attestation.parse( + "NSM1:04:f265b9a06a61b2b8c6eeed7fc00c7aa686ad511053467815bf1f1037d460e1f1" + ) + + XCTAssertNotNil(att) + XCTAssertEqual(att?.prefix, "NSM1") + XCTAssertEqual(att?.event, "DEPLOYMENT") + XCTAssertEqual(att?.isLegacy, true) + } + + func testParseGovernanceUppercaseHex() { + let att = Zap1Attestation.parse( + "ZAP1:0D:A487C25F5867A9E3760C45AE7EED24D84E771568F1826A889CCD94B3C7C3A5B5" + ) + + XCTAssertNotNil(att) + XCTAssertEqual(att?.typeHex, "0d") + XCTAssertEqual(att?.event, "GOVERNANCE_PROPOSAL") + } + + func testRejectsInvalidMemos() { + XCTAssertNil(Zap1Attestation.parse("Hello world")) + XCTAssertNil(Zap1Attestation.parse("ZAP1:xx:notahash")) + XCTAssertNil(Zap1Attestation.parse("ZAP1:01:tooshort")) + XCTAssertNil(Zap1Attestation.parse("")) + XCTAssertNil(Zap1Attestation.parse( + "ZAP2:01:075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" + )) + } + + func testFormatReturnsReadableString() { + let formatted = Zap1Attestation.format( + "ZAP1:09:024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" + ) + + XCTAssertEqual(formatted, "ZAP1: MERKLE_ROOT 024e36515ea3...") + } + + func testFormatReturnsNilForNonZap1() { + XCTAssertNil(Zap1Attestation.format("Just a regular memo")) + } + + func testTrimsWhitespaceAndNewlines() { + let att = Zap1Attestation.parse( + " ZAP1:01:075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b\n" + ) + + XCTAssertNotNil(att) + XCTAssertEqual(att?.event, "PROGRAM_ENTRY") + } +}