From a53d549e051853a9c7c2076689f8a22eb8d6fd1c Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Mon, 31 Aug 2026 12:20:01 -0400 Subject: [PATCH] Add account-bound permission token repository --- Package.swift | 5 +- .../PermissionTokenRepository.swift | 364 ++++++++++ .../PermissionTokenWallet.swift | 18 + .../PermissionTokenRepositoryTests.swift | 648 ++++++++++++++++++ 4 files changed, 1033 insertions(+), 2 deletions(-) create mode 100644 Sources/ToolboxPermissions/PermissionTokenRepository.swift create mode 100644 Sources/ToolboxPermissions/PermissionTokenWallet.swift create mode 100644 Tests/ToolboxPermissionsTests/PermissionTokenRepositoryTests.swift diff --git a/Package.swift b/Package.swift index 5286e92..fc1d08c 100644 --- a/Package.swift +++ b/Package.swift @@ -150,14 +150,15 @@ let package = Package( ] ), - // BRC-116's transport-neutral policy vocabulary and request classifier. This target owns - // no persistence, prompt UI, permission tokens, or wallet calls. + // BRC-116's transport-neutral policy, token codec, and account-bound read repository. + // Prompt UI and token mutation stay outside this boundary. .target( name: "ToolboxPermissions", dependencies: [ .product(name: "BSVCrypto", package: "swift-sdk"), .product(name: "BSVKeys", package: "swift-sdk"), .product(name: "BSVScript", package: "swift-sdk"), + .product(name: "BSVTransaction", package: "swift-sdk"), .product(name: "BSVWallet", package: "swift-sdk"), ] ), diff --git a/Sources/ToolboxPermissions/PermissionTokenRepository.swift b/Sources/ToolboxPermissions/PermissionTokenRepository.swift new file mode 100644 index 0000000..d571a90 --- /dev/null +++ b/Sources/ToolboxPermissions/PermissionTokenRepository.swift @@ -0,0 +1,364 @@ +import BSVScript +import BSVTransaction +import BSVWallet + +/// Immutable identity of the wallet account whose administrative baskets are queried. +public struct PermissionAccountID: Hashable, Sendable { + public let rawValue: String + + public init(_ rawValue: String) throws { + guard !rawValue.isEmpty else { throw PermissionTokenRepositoryError.invalidAccountID } + self.rawValue = rawValue + } +} + +/// Canonical token plus the exact unspent output that carried it. +public struct PermissionTokenMatch: Hashable, Sendable { + public let accountID: PermissionAccountID + public let token: PermissionToken + public let outpoint: Outpoint + public let satoshis: UInt64 + public let lockingScript: [UInt8] + public let sourceBEEF: BEEF + + package init( + accountID: PermissionAccountID, + token: PermissionToken, + outpoint: Outpoint, + satoshis: UInt64, + lockingScript: [UInt8], + sourceBEEF: BEEF + ) { + self.accountID = accountID + self.token = token + self.outpoint = outpoint + self.satoshis = satoshis + self.lockingScript = lockingScript + self.sourceBEEF = sourceBEEF + } +} + +public enum PermissionTokenRepositoryError: Error, Equatable, Sendable { + case invalidAccountID + case invalidated + case missingBEEF + case untrustworthyBEEF(outpoint: Outpoint) + case candidateLimitExceeded(total: UInt64, maximum: UInt64) + case paginationDidNotProgress(offset: UInt32) + case paginationOverflow(offset: UInt32, returned: UInt32) +} + +/// Account-bound, read-only access to BRC-116's canonical on-chain permission state. +/// +/// No positive authorization result is cached. Every lookup re-queries the wallet's +/// spendable admin basket and validates the returned output against its source BEEF. +public actor PermissionTokenRepository { + public nonisolated let accountID: PermissionAccountID + + nonisolated static let standardTransactionLimits: TransactionLimits = { + try! TransactionLimits( + maximumTransactionByteCount: 4 << 20, + maximumInputCount: 100_000, + maximumOutputCount: 100_000, + maximumScriptByteCount: 1 << 20 + ) + }() + + private static let pageSize: UInt32 = 100 + private static let maximumCandidateCount = UInt64(WalletABILimits.standard.maximumCollectionCount) + + private let wallet: any PermissionTokenWallet + private let transactionLimits: TransactionLimits + private var invalidationEpoch: UInt64 = 0 + private var isInvalidated = false + + public init(wallet: any PermissionTokenWallet) { + self.accountID = wallet.permissionAccountID + self.wallet = wallet + self.transactionLimits = Self.standardTransactionLimits + } + + package init( + wallet: any PermissionTokenWallet, + transactionLimits: TransactionLimits + ) { + self.accountID = wallet.permissionAccountID + self.wallet = wallet + self.transactionLimits = transactionLimits + } + + /// Permanently invalidates this account-bound repository. It cannot be rebound. + public func invalidate() { + isInvalidated = true + invalidationEpoch &+= 1 + } + + /// Finds a valid on-chain token covering `scope`, if one exists. + /// + /// Malformed token scripts and correctly-authenticated tokens for another scope are + /// ignored candidate-by-candidate. Missing or inconsistent BEEF is a repository + /// integrity failure, not "no permission". + public func findCovering( + _ scope: PermissionScopeKey, + nowUnixTime: UInt64 + ) async throws -> PermissionTokenMatch? { + try Task.checkCancellation() + let epoch = try activeEpoch() + let query = queryIdentity(for: scope) + var offset: UInt32 = 0 + var scannedCandidateCount: UInt64 = 0 + var seenOutpoints = Set() + var matches = [PermissionTokenMatch]() + + while true { + try Task.checkCancellation() + try requireActive(epoch) + let request = try WalletListOutputsRequest( + basket: query.basket.rawValue, + tags: query.tags, + tagQueryMode: .all, + include: .entireTransactions, + includeTags: true, + pagination: WalletPagination(limit: Self.pageSize, offset: offset), + seekPermission: false + ) + + let page: WalletListOutputsResult + do { + page = try await wallet.listPermissionTokenOutputs(request) + } catch is CancellationError { + throw CancellationError() + } catch { + try Task.checkCancellation() + try requireActive(epoch) + throw error + } + try Task.checkCancellation() + try requireActive(epoch) + + let returned = try exactUInt32(page.outputs.count, offset: offset) + guard UInt64(page.totalOutputs) <= Self.maximumCandidateCount else { + throw PermissionTokenRepositoryError.candidateLimitExceeded( + total: UInt64(page.totalOutputs), + maximum: Self.maximumCandidateCount + ) + } + let nextScannedCount = scannedCandidateCount + UInt64(returned) + guard nextScannedCount <= Self.maximumCandidateCount else { + throw PermissionTokenRepositoryError.candidateLimitExceeded( + total: nextScannedCount, + maximum: Self.maximumCandidateCount + ) + } + scannedCandidateCount = nextScannedCount + + if page.outputs.isEmpty { + if offset < page.totalOutputs { + throw PermissionTokenRepositoryError.paginationDidNotProgress(offset: offset) + } + break + } + guard let beef = page.beef else { + throw PermissionTokenRepositoryError.missingBEEF + } + + var newOutpointCount = 0 + for output in page.outputs { + try Task.checkCancellation() + try requireActive(epoch) + guard seenOutpoints.insert(output.outpoint).inserted else { continue } + newOutpointCount += 1 + + let source = try sourceOutput(for: output, in: beef) + guard output.spendable, source.satoshis == 1 else { continue } + + let token: PermissionToken + do { + try requireActive(epoch) + token = try await PermissionTokenCodec.decode( + source.lockingScript, + from: query.basket, + using: wallet + ) + try Task.checkCancellation() + try requireActive(epoch) + } catch is CancellationError { + throw CancellationError() + } catch { + try Task.checkCancellation() + try requireActive(epoch) + continue + } + + guard covers(token, requested: scope, nowUnixTime: nowUnixTime) else { continue } + matches.append(PermissionTokenMatch( + accountID: accountID, + token: token, + outpoint: output.outpoint, + satoshis: source.satoshis, + lockingScript: source.lockingScript.bytes, + sourceBEEF: beef + )) + } + + let nextOffset = try Self.checkedNextOffset(offset: offset, returned: returned) + if nextOffset >= page.totalOutputs { break } + guard newOutpointCount > 0 else { + throw PermissionTokenRepositoryError.paginationDidNotProgress(offset: offset) + } + offset = nextOffset + } + + try Task.checkCancellation() + try requireActive(epoch) + return bestMatch(in: matches, for: scope) + } + + private func activeEpoch() throws -> UInt64 { + guard !isInvalidated else { throw PermissionTokenRepositoryError.invalidated } + return invalidationEpoch + } + + private func requireActive(_ epoch: UInt64) throws { + guard !isInvalidated, invalidationEpoch == epoch else { + throw PermissionTokenRepositoryError.invalidated + } + } + + private func sourceOutput( + for metadata: WalletOutput, + in beef: BEEF + ) throws -> TransactionOutput { + let transaction: Transaction + do { + guard let candidate = try beef.transaction( + for: metadata.outpoint.transactionID, + limits: transactionLimits + ) else { + throw PermissionTokenRepositoryError.untrustworthyBEEF(outpoint: metadata.outpoint) + } + transaction = candidate + } catch let error as PermissionTokenRepositoryError { + throw error + } catch { + throw PermissionTokenRepositoryError.untrustworthyBEEF(outpoint: metadata.outpoint) + } + guard let index = Int(exactly: metadata.outpoint.outputIndex), + transaction.outputs.indices.contains(index) else { + throw PermissionTokenRepositoryError.untrustworthyBEEF(outpoint: metadata.outpoint) + } + let source = transaction.outputs[index] + guard source.satoshis == metadata.satoshis, + metadata.lockingScript == nil || metadata.lockingScript == source.lockingScript.bytes else { + throw PermissionTokenRepositoryError.untrustworthyBEEF(outpoint: metadata.outpoint) + } + return source + } + + private func exactUInt32(_ count: Int, offset: UInt32) throws -> UInt32 { + guard let value = UInt32(exactly: count) else { + throw PermissionTokenRepositoryError.paginationOverflow(offset: offset, returned: .max) + } + return value + } + + static func checkedNextOffset(offset: UInt32, returned: UInt32) throws -> UInt32 { + let (nextOffset, overflow) = offset.addingReportingOverflow(returned) + guard !overflow, nextOffset > offset else { + throw PermissionTokenRepositoryError.paginationOverflow( + offset: offset, + returned: returned + ) + } + return nextOffset + } +} + +private extension PermissionTokenRepository { + struct QueryIdentity { + let basket: PermissionTokenBasket + let tags: [String] + } + + func queryIdentity(for scope: PermissionScopeKey) -> QueryIdentity { + switch scope { + case .protocolAccess(let value): + var tags = [ + "originator \(value.originator.rawValue)", + "privileged \(value.privileged)", + "protocolName \(value.protocolName)", + "protocolSecurityLevel \(value.securityLevel.rawValue)", + ] + if value.securityLevel == .applicationAndCounterparty, + let counterparty = value.counterparty { + tags.append("counterparty \(counterparty.rawValue)") + } + return QueryIdentity(basket: .protocolPermission, tags: tags) + + case .basketAccess(let value): + return QueryIdentity( + basket: .basketAccess, + tags: ["originator \(value.originator.rawValue)", "basket \(value.basket)"] + ) + + case .certificateAccess(let value): + return QueryIdentity( + basket: .certificateAccess, + tags: [ + "originator \(value.originator.rawValue)", + "privileged \(value.privileged)", + "type \(value.certificateType)", + "verifier \(value.verifier.rawValue)", + ] + ) + + case .spendingAuthorization(let value): + return QueryIdentity( + basket: .spendingAuthorization, + tags: ["originator \(value.originator.rawValue)"] + ) + } + } + + func covers( + _ token: PermissionToken, + requested: PermissionScopeKey, + nowUnixTime: UInt64 + ) -> Bool { + guard !token.isExpired(at: nowUnixTime) else { return false } + switch (token, requested) { + case (.dpacp(let grant), .protocolAccess(let request)): + return grant.scope == request + case (.dbap(let grant), .basketAccess(let request)): + return grant.scope == request + case (.dcap(let grant), .certificateAccess(let request)): + return grant.scope.originator == request.originator + && grant.scope.privileged == request.privileged + && grant.scope.certificateType == request.certificateType + && grant.scope.verifier == request.verifier + && grant.covers(fields: request.fields) + case (.dsap(let grant), .spendingAuthorization(let request)): + return grant.scope == request + default: + return false + } + } + + func bestMatch( + in matches: [PermissionTokenMatch], + for scope: PermissionScopeKey + ) -> PermissionTokenMatch? { + guard case .spendingAuthorization = scope else { return matches.first } + return matches.max { lhs, rhs in + let lhsAmount = dsapAmount(lhs.token) + let rhsAmount = dsapAmount(rhs.token) + if lhsAmount != rhsAmount { return lhsAmount < rhsAmount } + return lhs.outpoint.description > rhs.outpoint.description + } + } + + func dsapAmount(_ token: PermissionToken) -> UInt64 { + guard case .dsap(let value) = token else { return 0 } + return value.authorizedAmount + } +} diff --git a/Sources/ToolboxPermissions/PermissionTokenWallet.swift b/Sources/ToolboxPermissions/PermissionTokenWallet.swift new file mode 100644 index 0000000..515724b --- /dev/null +++ b/Sources/ToolboxPermissions/PermissionTokenWallet.swift @@ -0,0 +1,18 @@ +import BSVWallet + +/// The narrow wallet surface needed to read canonical BRC-116 permission tokens. +/// +/// This deliberately does not inherit `WalletInterface`: an adapter can expose only +/// account-bound token queries plus the cryptographic operations used by the codec. +public protocol PermissionTokenWallet: + WalletPublicKeyProviding, + WalletCipherOperations, + WalletSignatureOperations, + Sendable +{ + var permissionAccountID: PermissionAccountID { get } + + func listPermissionTokenOutputs( + _ request: WalletListOutputsRequest + ) async throws -> WalletListOutputsResult +} diff --git a/Tests/ToolboxPermissionsTests/PermissionTokenRepositoryTests.swift b/Tests/ToolboxPermissionsTests/PermissionTokenRepositoryTests.swift new file mode 100644 index 0000000..5e9c505 --- /dev/null +++ b/Tests/ToolboxPermissionsTests/PermissionTokenRepositoryTests.swift @@ -0,0 +1,648 @@ +import BSVKeys +import BSVScript +import BSVTransaction +import BSVWallet +import XCTest +@testable import ToolboxPermissions + +final class PermissionTokenRepositoryTests: XCTestCase { + func testExactQueryAndPageTwoValidTokenAfterExpiredCandidate() async throws { + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(21)) + let scope = BasketPermissionScope( + originator: try CanonicalOriginator("HTTPS://EXAMPLE.COM:443/path"), + basket: "payments" + ) + let expired = PermissionToken.dbap(try .init(scope: scope, expiry: 99)) + let valid = PermissionToken.dbap(try .init(scope: scope, expiry: 0)) + let expiredCandidate = try await candidate(expired, wallet: wallet) + let validCandidate = try await candidate(valid, wallet: wallet) + await wallet.setPage( + offset: 0, + result: try page( + total: 101, + candidates: Array(repeating: expiredCandidate, count: 100) + ) + ) + await wallet.setPage(offset: 100, result: try page(total: 101, candidates: [validCandidate])) + + let repository = try repository(wallet: wallet) + let match = try await repository.findCovering(.basketAccess(scope), nowUnixTime: 100) + + XCTAssertEqual(match?.token, valid) + XCTAssertEqual(match?.satoshis, 1) + XCTAssertEqual(match?.outpoint, validCandidate.output.outpoint) + XCTAssertEqual(match?.lockingScript, validCandidate.output.lockingScript) + let requests = await wallet.recordedRequests() + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests.map(\.basket), ["admin basket-access", "admin basket-access"]) + XCTAssertEqual(requests[0].tags, ["originator example.com", "basket payments"]) + XCTAssertEqual(requests[0].tagQueryMode, .all) + XCTAssertEqual(requests[0].include, .entireTransactions) + XCTAssertEqual(requests[0].includeTags, true) + XCTAssertEqual(requests[0].seekPermission, false) + XCTAssertEqual(requests[0].pagination.limit, 100) + XCTAssertEqual(requests.map { $0.pagination.offset }, [0, 100]) + } + + func testTagHitWithDifferentDecryptedScopeNeverAuthorizes() async throws { + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(22)) + let requested = BasketPermissionScope( + originator: try CanonicalOriginator("example.com"), basket: "payments" + ) + let other = PermissionToken.dbap(try .init( + scope: .init(originator: try CanonicalOriginator("evil.example"), basket: "payments"), + expiry: 0 + )) + await wallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [other], wallet: wallet) + ) + + let result = try await repository(wallet: wallet).findCovering( + .basketAccess(requested), nowUnixTime: 1 + ) + XCTAssertNil(result) + } + + func testMalformedCandidateDoesNotHideLaterValidToken() async throws { + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(40)) + let token = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 0)) + let malformedTransaction = Transaction(outputs: [TransactionOutput( + satoshis: 1, + lockingScript: try Script(bytes: [0x51], maximumByteCount: 1) + )]) + let malformedID = try malformedTransaction.transactionID( + limits: PermissionTokenRepository.standardTransactionLimits + ) + let malformed = RepositoryCandidate( + output: try WalletOutput( + satoshis: 1, + lockingScript: [0x51], + spendable: true, + outpoint: Outpoint(transactionID: malformedID, outputIndex: 0) + ), + transaction: malformedTransaction + ) + let valid = try await candidate(token, wallet: wallet) + await wallet.setPage( + offset: 0, + result: try page(total: 2, candidates: [malformed, valid]) + ) + + let match = try await repository(wallet: wallet).findCovering( + .basketAccess(try dbapScope()), nowUnixTime: 1 + ) + XCTAssertEqual(match?.token, token) + } + + func testDPACPLevelOneErasesCounterpartyAndLevelTwoRequiresExactCounterparty() async throws { + let originator = try CanonicalOriginator("example.com") + let levelOneWallet = RepositoryTestWallet(rootKey: try testPrivateKey(23)) + let levelOneScope = try ProtocolPermissionScope( + originator: originator, + privileged: false, + securityLevel: .application, + protocolName: "messages", + counterparty: CanonicalCounterparty(publicKey: try testKey(24)) + ) + XCTAssertNil(levelOneScope.counterparty) + let levelOne = PermissionToken.dpacp(try .init(scope: levelOneScope, expiry: 0)) + await levelOneWallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [levelOne], wallet: levelOneWallet) + ) + let levelOneMatch = try await repository(wallet: levelOneWallet).findCovering( + .protocolAccess(levelOneScope), nowUnixTime: 1 + ) + XCTAssertNotNil(levelOneMatch) + let levelOneRequests = await levelOneWallet.recordedRequests() + XCTAssertEqual(levelOneRequests.first?.tags, [ + "originator example.com", "privileged false", "protocolName messages", + "protocolSecurityLevel 1", + ]) + + let levelTwoWallet = RepositoryTestWallet(rootKey: try testPrivateKey(25)) + let grantedCounterparty = CanonicalCounterparty(publicKey: try testKey(26)) + let otherCounterparty = CanonicalCounterparty(publicKey: try testKey(27)) + let grantedScope = try ProtocolPermissionScope( + originator: originator, + privileged: true, + securityLevel: .applicationAndCounterparty, + protocolName: "messages", + counterparty: grantedCounterparty + ) + let requestedScope = try ProtocolPermissionScope( + originator: originator, + privileged: true, + securityLevel: .applicationAndCounterparty, + protocolName: "messages", + counterparty: otherCounterparty + ) + let granted = PermissionToken.dpacp(try .init(scope: grantedScope, expiry: 0)) + await levelTwoWallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [granted], wallet: levelTwoWallet) + ) + let levelTwoMatch = try await repository(wallet: levelTwoWallet).findCovering( + .protocolAccess(requestedScope), nowUnixTime: 1 + ) + XCTAssertNil(levelTwoMatch) + let levelTwoRequests = await levelTwoWallet.recordedRequests() + XCTAssertEqual(levelTwoRequests.first?.tags.last, + "counterparty \(otherCounterparty.rawValue)") + } + + func testDCAPRequestedFieldsMustBeSubsetOfGrantedFields() async throws { + let originator = try CanonicalOriginator("example.com") + let verifier = CanonicalCounterparty(publicKey: try testKey(28)) + let grantedScope = CertificatePermissionScope( + originator: originator, + privileged: false, + certificateType: "identity", + verifier: verifier, + fields: ["name", "email"] + ) + let granted = PermissionToken.dcap(try .init(scope: grantedScope, expiry: 0)) + + let subsetWallet = RepositoryTestWallet(rootKey: try testPrivateKey(29)) + await subsetWallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [granted], wallet: subsetWallet) + ) + let subset = CertificatePermissionScope( + originator: originator, + privileged: false, + certificateType: "identity", + verifier: verifier, + fields: ["name"] + ) + let subsetMatch = try await repository(wallet: subsetWallet).findCovering( + .certificateAccess(subset), nowUnixTime: 1 + ) + XCTAssertNotNil(subsetMatch) + + let supersetWallet = RepositoryTestWallet(rootKey: try testPrivateKey(30)) + let narrowGrant = PermissionToken.dcap(try .init(scope: subset, expiry: 0)) + await supersetWallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [narrowGrant], wallet: supersetWallet) + ) + let supersetMatch = try await repository(wallet: supersetWallet).findCovering( + .certificateAccess(grantedScope), nowUnixTime: 1 + ) + XCTAssertNil(supersetMatch) + let subsetRequests = await subsetWallet.recordedRequests() + XCTAssertEqual(subsetRequests.first?.tags, [ + "originator example.com", "privileged false", "type identity", + "verifier \(verifier.rawValue)", + ]) + } + + func testDSAPSelectsGreatestSingleAuthorizationWithoutSumming() async throws { + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(31)) + let scope = SpendingPermissionScope(originator: try CanonicalOriginator("example.com")) + let tokens: [PermissionToken] = [5, 9, 7].map { + .dsap(.init(scope: scope, authorizedAmount: UInt64($0))) + } + await wallet.setPage( + offset: 0, + result: try await page(total: 3, tokens: tokens, wallet: wallet) + ) + + let match = try await repository(wallet: wallet).findCovering( + .spendingAuthorization(scope), nowUnixTime: .max + ) + guard case .dsap(let token) = match?.token else { + return XCTFail("Expected DSAP match") + } + XCTAssertEqual(token.authorizedAmount, 9) + XCTAssertNotEqual(token.authorizedAmount, 21) + } + + func testMissingAndInconsistentBEEFFailClosed() async throws { + let token = PermissionToken.dbap(try .init( + scope: .init(originator: try CanonicalOriginator("example.com"), basket: "payments"), + expiry: 0 + )) + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(32)) + let valid = try await candidate(token, wallet: wallet) + + await wallet.setPage(offset: 0, result: try WalletListOutputsResult( + totalOutputs: 1, beef: nil, outputs: [valid.output] + )) + await assertRepositoryError(.missingBEEF) { + try await self.repository(wallet: wallet).findCovering( + .basketAccess(try self.dbapScope()), nowUnixTime: 1 + ) + } + + let unrelated = Transaction(outputs: [TransactionOutput( + satoshis: 1, + lockingScript: try Script(bytes: [0x51], maximumByteCount: 1) + )]) + await wallet.setPage(offset: 0, result: try WalletListOutputsResult( + totalOutputs: 1, + beef: try beef([unrelated]), + outputs: [valid.output] + )) + await assertUntrustworthy { + try await self.repository(wallet: wallet).findCovering( + .basketAccess(try self.dbapScope()), nowUnixTime: 1 + ) + } + + let badVout = try WalletOutput( + satoshis: 1, + lockingScript: valid.output.lockingScript, + spendable: true, + outpoint: Outpoint( + transactionID: valid.output.outpoint.transactionID, + outputIndex: 1 + ) + ) + await wallet.setPage(offset: 0, result: try WalletListOutputsResult( + totalOutputs: 1, beef: try beef([valid.transaction]), outputs: [badVout] + )) + await assertUntrustworthy { + try await self.repository(wallet: wallet).findCovering( + .basketAccess(try self.dbapScope()), nowUnixTime: 1 + ) + } + + for output in [ + try WalletOutput( + satoshis: 2, + lockingScript: valid.output.lockingScript, + spendable: true, + outpoint: valid.output.outpoint + ), + try WalletOutput( + satoshis: 1, + lockingScript: [0x51], + spendable: true, + outpoint: valid.output.outpoint + ), + ] { + await wallet.setPage(offset: 0, result: try WalletListOutputsResult( + totalOutputs: 1, beef: try beef([valid.transaction]), outputs: [output] + )) + await assertUntrustworthy { + try await self.repository(wallet: wallet).findCovering( + .basketAccess(try self.dbapScope()), nowUnixTime: 1 + ) + } + } + } + + func testNonSpendableAndNonOneSatOutputsNeverAuthorize() async throws { + let token = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 0)) + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(33)) + let stale = try await candidate(token, wallet: wallet, spendable: false) + await wallet.setPage(offset: 0, result: try page(total: 1, candidates: [stale])) + let staleMatch = try await repository(wallet: wallet).findCovering( + .basketAccess(try dbapScope()), nowUnixTime: 1 + ) + XCTAssertNil(staleMatch) + + let twoSat = try await candidate(token, wallet: wallet, satoshis: 2) + await wallet.setPage(offset: 0, result: try page(total: 1, candidates: [twoSat])) + let twoSatMatch = try await repository(wallet: wallet).findCovering( + .basketAccess(try dbapScope()), nowUnixTime: 1 + ) + XCTAssertNil(twoSatMatch) + } + + func testAccountBindingAndPermanentInvalidation() async throws { + XCTAssertThrowsError(try PermissionAccountID("")) { error in + XCTAssertEqual(error as? PermissionTokenRepositoryError, .invalidAccountID) + } + let token = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 0)) + let firstID = try PermissionAccountID("account-one") + let secondID = try PermissionAccountID("account-two") + let firstWallet = RepositoryTestWallet(rootKey: try testPrivateKey(38), accountID: firstID) + let secondWallet = RepositoryTestWallet(rootKey: try testPrivateKey(39), accountID: secondID) + await firstWallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [token], wallet: firstWallet) + ) + await secondWallet.setPage( + offset: 0, + result: try await page(total: 1, tokens: [token], wallet: secondWallet) + ) + let first = PermissionTokenRepository(wallet: firstWallet) + let second = PermissionTokenRepository(wallet: secondWallet) + let firstMatch = try await first.findCovering( + .basketAccess(try dbapScope()), nowUnixTime: 1 + ) + XCTAssertEqual(firstMatch?.accountID, firstID) + let secondMatch = try await second.findCovering( + .basketAccess(try dbapScope()), nowUnixTime: 1 + ) + XCTAssertEqual(secondMatch?.accountID, secondID) + + await first.invalidate() + await assertRepositoryError(.invalidated) { + try await first.findCovering(.basketAccess(try self.dbapScope()), nowUnixTime: 1) + } + } + + func testInvalidationAndCancellationDuringPagingFailCurrentLookup() async throws { + try await assertInterruptedLookup(invalidate: true) + try await assertInterruptedLookup(invalidate: false) + } + + func testPaginationNonProgressAndOverflowChecks() async throws { + XCTAssertThrowsError(try PermissionTokenRepository.checkedNextOffset( + offset: .max - 1, returned: 2 + )) { error in + XCTAssertEqual(error as? PermissionTokenRepositoryError, + .paginationOverflow(offset: .max - 1, returned: 2)) + } + + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(35)) + let token = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 0)) + let expired = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 1)) + let candidate = try await candidate(expired, wallet: wallet) + await wallet.setPage( + offset: 0, + result: try page(total: 101, candidates: Array(repeating: candidate, count: 100)) + ) + await wallet.setPage( + offset: 100, + result: try WalletListOutputsResult(totalOutputs: 101, beef: nil, outputs: []) + ) + _ = token // keep the requested scope independently valid; no candidate may authorize it. + await assertRepositoryError(.paginationDidNotProgress(offset: 100)) { + try await self.repository(wallet: wallet).findCovering( + .basketAccess(try self.dbapScope()), nowUnixTime: 2 + ) + } + } + + func testHostileTotalIsRejectedAfterOneRequest() async throws { + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(41)) + let token = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 0)) + await wallet.setPage( + offset: 0, + result: try await page(total: .max, tokens: [token], wallet: wallet) + ) + + await assertRepositoryError( + .candidateLimitExceeded(total: UInt64(UInt32.max), maximum: 10_000) + ) { + try await self.repository(wallet: wallet).findCovering( + .basketAccess(try self.dbapScope()), nowUnixTime: 1 + ) + } + let requestCount = await wallet.recordedRequestCount() + XCTAssertEqual(requestCount, 1) + } + + private func assertInterruptedLookup(invalidate: Bool) async throws { + let wallet = RepositoryTestWallet(rootKey: try testPrivateKey(invalidate ? 36 : 37)) + let expired = PermissionToken.dbap(try .init(scope: try dbapScope(), expiry: 1)) + let firstCandidate = try await candidate(expired, wallet: wallet) + await wallet.setPage( + offset: 0, + result: try page( + total: 101, + candidates: Array(repeating: firstCandidate, count: 100) + ) + ) + await wallet.setPage( + offset: 100, + result: try WalletListOutputsResult(totalOutputs: 101, beef: nil, outputs: []) + ) + let gate = AsyncRepositoryGate() + await wallet.setGate(gate, offset: 100) + let repository = try repository(wallet: wallet) + let requestedScope = try dbapScope() + let task = Task { + try await repository.findCovering(.basketAccess(requestedScope), nowUnixTime: 2) + } + await gate.waitUntilEntered() + if invalidate { + await repository.invalidate() + } else { + task.cancel() + } + await gate.release() + do { + _ = try await task.value + XCTFail("Expected interrupted lookup") + } catch is CancellationError where !invalidate { + // Expected. + } catch let error as PermissionTokenRepositoryError where invalidate { + XCTAssertEqual(error, .invalidated) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func dbapScope() throws -> BasketPermissionScope { + .init(originator: try CanonicalOriginator("example.com"), basket: "payments") + } + + private func repository(wallet: RepositoryTestWallet) throws -> PermissionTokenRepository { + PermissionTokenRepository(wallet: wallet) + } +} + +private struct RepositoryCandidate: Sendable { + let output: WalletOutput + let transaction: Transaction +} + +private func candidate( + _ token: PermissionToken, + wallet: RepositoryTestWallet, + spendable: Bool = true, + satoshis: UInt64 = 1 +) async throws -> RepositoryCandidate { + let script = try await PermissionTokenCodec.encode(token, using: wallet) + let transaction = Transaction(outputs: [TransactionOutput( + satoshis: satoshis, + lockingScript: script + )]) + let transactionID = try transaction.transactionID( + limits: PermissionTokenRepository.standardTransactionLimits + ) + return RepositoryCandidate( + output: try WalletOutput( + satoshis: satoshis, + lockingScript: script.bytes, + spendable: spendable, + tags: [], + outpoint: Outpoint(transactionID: transactionID, outputIndex: 0) + ), + transaction: transaction + ) +} + +private func page( + total: UInt32, + tokens: [PermissionToken], + wallet: RepositoryTestWallet +) async throws -> WalletListOutputsResult { + var candidates = [RepositoryCandidate]() + for token in tokens { + candidates.append(try await candidate(token, wallet: wallet)) + } + return try page(total: total, candidates: candidates) +} + +private func page( + total: UInt32, + candidates: [RepositoryCandidate] +) throws -> WalletListOutputsResult { + try WalletListOutputsResult( + totalOutputs: total, + beef: try beef(candidates.map(\.transaction).uniqued()), + outputs: candidates.map(\.output) + ) +} + +private func beef(_ transactions: [Transaction]) throws -> BEEF { + let transactionLimits = PermissionTokenRepository.standardTransactionLimits + let merkleLimits = try MerklePathLimits( + maximumByteCount: 1 << 20, + maximumLeavesPerLevel: 100_000, + maximumTotalLeaves: 1_000_000 + ) + let limits = try BEEFLimits( + maximumByteCount: 8 << 20, + maximumMerklePathCount: 10_000, + maximumTransactionCount: 10_000, + transactionLimits: transactionLimits, + merklePathLimits: merkleLimits + ) + return try BEEF( + version: .v2, + merklePaths: [], + transactions: transactions.map(BEEFTransaction.raw), + limits: limits + ) +} + +private extension Array where Element: Hashable { + func uniqued() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} + +private actor AsyncRepositoryGate { + private var entered = false + private var released = false + private var entryWaiters = [CheckedContinuation]() + private var releaseWaiters = [CheckedContinuation]() + + func wait() async { + entered = true + let entries = entryWaiters + entryWaiters.removeAll() + entries.forEach { $0.resume() } + guard !released else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilEntered() async { + guard !entered else { return } + await withCheckedContinuation { entryWaiters.append($0) } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor RepositoryTestWallet: PermissionTokenWallet { + nonisolated let protoWallet: ProtoWallet + nonisolated let permissionAccountID: PermissionAccountID + private var pages = [UInt32: WalletListOutputsResult]() + private var requests = [WalletListOutputsRequest]() + private var gates = [UInt32: AsyncRepositoryGate]() + + init(rootKey: PrivateKey, accountID: PermissionAccountID = repositoryTestAccountID) { + protoWallet = ProtoWallet(rootKey: rootKey) + permissionAccountID = accountID + } + + func setPage(offset: UInt32, result: WalletListOutputsResult) { + pages[offset] = result + } + + func setGate(_ gate: AsyncRepositoryGate, offset: UInt32) { + gates[offset] = gate + } + + func recordedRequests() -> [WalletListOutputsRequest] { requests } + func recordedRequestCount() -> Int { requests.count } + + func listPermissionTokenOutputs( + _ request: WalletListOutputsRequest + ) async throws -> WalletListOutputsResult { + requests.append(request) + let offset = request.pagination.effectiveOffset + if let gate = gates[offset] { await gate.wait() } + try Task.checkCancellation() + if let result = pages[offset] { return result } + return try WalletListOutputsResult(totalOutputs: 0, outputs: []) + } + + nonisolated func getPublicKey( + _ request: WalletGetPublicKeyRequest + ) async throws -> WalletGetPublicKeyResult { + try await protoWallet.getPublicKey(request) + } + + nonisolated func encrypt(_ request: WalletEncryptRequest) async throws -> WalletEncryptResult { + try await protoWallet.encrypt(request) + } + + nonisolated func decrypt(_ request: WalletDecryptRequest) async throws -> WalletDecryptResult { + try await protoWallet.decrypt(request) + } + + nonisolated func createSignature( + _ request: WalletCreateSignatureRequest + ) async throws -> WalletCreateSignatureResult { + try await protoWallet.createSignature(request) + } + + nonisolated func verifySignature( + _ request: WalletVerifySignatureRequest + ) async throws -> WalletVerifySignatureResult { + try await protoWallet.verifySignature(request) + } +} + +private let repositoryTestAccountID = try! PermissionAccountID("account") + +private func assertRepositoryError( + _ expected: PermissionTokenRepositoryError, + operation: () async throws -> T +) async { + do { + _ = try await operation() + XCTFail("Expected \(expected)") + } catch let error as PermissionTokenRepositoryError { + XCTAssertEqual(error, expected) + } catch { + XCTFail("Unexpected error: \(error)") + } +} + +private func assertUntrustworthy(operation: () async throws -> T) async { + do { + _ = try await operation() + XCTFail("Expected untrustworthy BEEF") + } catch PermissionTokenRepositoryError.untrustworthyBEEF { + // Expected. + } catch { + XCTFail("Unexpected error: \(error)") + } +}