|
| 1 | +import { MandalaTopicManager, unlinkedTokenReason } from '../MandalaTopicManager.js' |
| 2 | +import { InMemoryScreeningProvider, encodeLinkagePayload, MandalaLinkagePayload } from '../types.js' |
| 3 | +import { defaultAssetState } from '../AssetStateReducer.js' |
| 4 | +import { MandalaToken } from '@bsv/templates' |
| 5 | +import { ProtoWallet, PrivateKey, Hash, Utils, WalletProtocol, Transaction, P2PKH, UnlockingScript } from '@bsv/sdk' |
| 6 | + |
| 7 | +// A token-shaped output the overlay cannot attribute to a verified linkage |
| 8 | +// rejects the WHOLE transaction. Skipping it admitted its siblings, signed the |
| 9 | +// admission and broadcast — a phantom coin mined inside an attested tx. |
| 10 | + |
| 11 | +const protocolID: WalletProtocol = [2, 'mandala token'] |
| 12 | +const keyID = 'tkn' |
| 13 | +const assetId = `${'a'.repeat(64)}.0` |
| 14 | + |
| 15 | +const sender = new ProtoWallet(PrivateKey.fromRandom()) |
| 16 | +const receiver = new ProtoWallet(PrivateKey.fromRandom()) |
| 17 | +const other = new ProtoWallet(PrivateKey.fromRandom()) |
| 18 | +const overlay = new ProtoWallet(PrivateKey.fromRandom()) |
| 19 | + |
| 20 | +const manager = new MandalaTopicManager({ |
| 21 | + verifierWallet: overlay as any, |
| 22 | + screeningProvider: new InMemoryScreeningProvider([]), |
| 23 | + adminWallet: overlay as any, |
| 24 | + adminProtocolID: [2, 'mandala admin'], |
| 25 | + stateStore: { |
| 26 | + getAssetState: async () => defaultAssetState(assetId), |
| 27 | + getTokenRow: async () => null, |
| 28 | + isAdminOutpoint: async () => true |
| 29 | + } |
| 30 | +}) |
| 31 | + |
| 32 | +const identity = async (w: ProtoWallet): Promise<string> => (await w.getPublicKey({ identityKey: true })).publicKey |
| 33 | + |
| 34 | +/** A conserved 100-unit transfer: one token input, `outputs` token outputs summing to 100. */ |
| 35 | +async function transfer (amounts: number[]): Promise<{ tx: Transaction, linkageFor: (counterparty: ProtoWallet) => Promise<any> }> { |
| 36 | + const [receiverKey, verifierKey] = await Promise.all([identity(receiver), identity(overlay)]) |
| 37 | + const { publicKey: derived } = await sender.getPublicKey({ protocolID, keyID, counterparty: receiverKey }) |
| 38 | + const pkh = Hash.hash160(Utils.toArray(derived, 'hex')) |
| 39 | + const source = new Transaction() |
| 40 | + source.addOutput({ lockingScript: new MandalaToken().lock(assetId, 100, pkh), satoshis: 1 }) |
| 41 | + const tx = new Transaction() |
| 42 | + tx.addInput({ sourceTransaction: source, sourceOutputIndex: 0, unlockingScript: new UnlockingScript() }) |
| 43 | + for (const amount of amounts) tx.addOutput({ lockingScript: new MandalaToken().lock(assetId, amount, pkh), satoshis: 1 }) |
| 44 | + tx.addOutput({ lockingScript: new P2PKH().lock(Hash.hash160(Utils.toArray('01', 'hex'))), satoshis: 500 }) |
| 45 | + const linkageFor = async (counterparty: ProtoWallet): Promise<any> => |
| 46 | + await sender.revealSpecificKeyLinkage({ counterparty: await identity(counterparty), verifier: verifierKey, protocolID, keyID }) |
| 47 | + return { tx, linkageFor } |
| 48 | +} |
| 49 | + |
| 50 | +const run = async (tx: Transaction, payload: MandalaLinkagePayload): Promise<{ outputsToAdmit: number[] }> => |
| 51 | + await manager.identifyAdmissibleOutputs(tx.toBEEF(), [0], encodeLinkagePayload(payload)) as any |
| 52 | + |
| 53 | +describe('MandalaTopicManager rejects any token output without a verified linkage', () => { |
| 54 | + it('names the reason exactly as the wire contract does', () => { |
| 55 | + expect(unlinkedTokenReason(3)).toBe('output 3: MandalaToken-decodable output with no verified linkage') |
| 56 | + }) |
| 57 | + |
| 58 | + it('admits a transfer whose every token output is linked', async () => { |
| 59 | + const { tx, linkageFor } = await transfer([60, 40]) |
| 60 | + const linkage = await linkageFor(receiver) |
| 61 | + const res = await run(tx, { inputs: [], outputs: [{ index: 0, linkage }, { index: 1, linkage }] }) |
| 62 | + expect(res.outputsToAdmit).toEqual([0, 1]) |
| 63 | + }) |
| 64 | + |
| 65 | + it('rejects when one token output has no linkage, even though its siblings verify', async () => { |
| 66 | + const { tx, linkageFor } = await transfer([60, 40]) |
| 67 | + const linkage = await linkageFor(receiver) |
| 68 | + await expect(run(tx, { inputs: [], outputs: [{ index: 0, linkage }] })) |
| 69 | + .rejects.toThrow(unlinkedTokenReason(1)) |
| 70 | + }) |
| 71 | + |
| 72 | + it('rejects a linkage that verifies to a key other than the one the output is locked to', async () => { |
| 73 | + const { tx, linkageFor } = await transfer([100]) |
| 74 | + const wrongParty = await linkageFor(other) |
| 75 | + await expect(run(tx, { inputs: [], outputs: [{ index: 0, linkage: wrongParty }] })) |
| 76 | + .rejects.toThrow(unlinkedTokenReason(0)) |
| 77 | + }) |
| 78 | + |
| 79 | + it('rejects a malformed linkage the verifier cannot open', async () => { |
| 80 | + const { tx, linkageFor } = await transfer([100]) |
| 81 | + const linkage = await linkageFor(receiver) |
| 82 | + const garbled = { ...linkage, encryptedLinkage: [1, 2, 3] } |
| 83 | + await expect(run(tx, { inputs: [], outputs: [{ index: 0, linkage: garbled }] })) |
| 84 | + .rejects.toThrow(unlinkedTokenReason(0)) |
| 85 | + }) |
| 86 | + |
| 87 | + it('leaves plain, non-token outputs out of the rule', async () => { |
| 88 | + const { tx, linkageFor } = await transfer([100]) |
| 89 | + const linkage = await linkageFor(receiver) |
| 90 | + // Output 1 is ordinary P2PKH change with no linkage: not this rule's business. |
| 91 | + const res = await run(tx, { inputs: [], outputs: [{ index: 0, linkage }] }) |
| 92 | + expect(res.outputsToAdmit).toEqual([0]) |
| 93 | + }) |
| 94 | +}) |
0 commit comments