@kaspacom/kcc20-tx-builder 0.8.0-fee.1 → 0.8.0-fee.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/RELEASING.md +8 -7
  3. package/dist/cjs/engine.js +252 -0
  4. package/dist/cjs/engine.js.map +1 -1
  5. package/dist/cjs/escrow-account-setup.d.ts +53 -0
  6. package/dist/cjs/escrow-account-setup.js +103 -0
  7. package/dist/cjs/escrow-account-setup.js.map +1 -0
  8. package/dist/cjs/escrow-claim-plan.d.ts +74 -0
  9. package/dist/cjs/escrow-claim-plan.js +98 -0
  10. package/dist/cjs/escrow-claim-plan.js.map +1 -0
  11. package/dist/cjs/escrow-receipt.d.ts +103 -0
  12. package/dist/cjs/escrow-receipt.js +293 -0
  13. package/dist/cjs/escrow-receipt.js.map +1 -0
  14. package/dist/cjs/index.d.ts +3 -0
  15. package/dist/cjs/index.js +3 -0
  16. package/dist/cjs/index.js.map +1 -1
  17. package/dist/cjs/operations.d.ts +1 -1
  18. package/dist/cjs/operations.js +2 -0
  19. package/dist/cjs/operations.js.map +1 -1
  20. package/dist/engine.d.ts.map +1 -1
  21. package/dist/engine.js +252 -0
  22. package/dist/engine.js.map +1 -1
  23. package/dist/escrow-account-setup.d.ts +54 -0
  24. package/dist/escrow-account-setup.d.ts.map +1 -0
  25. package/dist/escrow-account-setup.js +99 -0
  26. package/dist/escrow-account-setup.js.map +1 -0
  27. package/dist/escrow-claim-plan.d.ts +75 -0
  28. package/dist/escrow-claim-plan.d.ts.map +1 -0
  29. package/dist/escrow-claim-plan.js +95 -0
  30. package/dist/escrow-claim-plan.js.map +1 -0
  31. package/dist/escrow-receipt.d.ts +104 -0
  32. package/dist/escrow-receipt.d.ts.map +1 -0
  33. package/dist/escrow-receipt.js +287 -0
  34. package/dist/escrow-receipt.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/operations.d.ts +1 -1
  40. package/dist/operations.d.ts.map +1 -1
  41. package/dist/operations.js +2 -0
  42. package/dist/operations.js.map +1 -1
  43. package/docs/COMPATIBILITY.json +1 -1
  44. package/docs/escrow-fees.md +47 -3
  45. package/package.json +3 -3
  46. package/src/engine.ts +292 -0
  47. package/src/escrow-account-setup.ts +124 -0
  48. package/src/escrow-claim-plan.ts +130 -0
  49. package/src/escrow-receipt.ts +333 -0
  50. package/src/index.ts +3 -0
  51. package/src/operations.ts +2 -0
package/src/engine.ts CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  ownerAuthorizationWitness,
78
78
  } from "./abi.js";
79
79
  import { KCC20_BUILDER_KEYS } from "./operations.js";
80
+ import { buildKcc20InitialFeeAccount, planKcc20FeeAccountBinding } from "./escrow-account-setup.js";
80
81
  import { createKcc20SnapshotPsktBuilder } from "./snapshot-operation.js";
81
82
  import { splitKcc20MintSupply } from "./protocol.js";
82
83
  import { buildKcc20DeployIntentOutputs } from "./deploy-receipt.js";
@@ -553,6 +554,8 @@ export function createKcc20PsktBuilderEngine(
553
554
  "fee-ticket.create-from-utility-burn": buildFeeTicketCreatePskt,
554
555
  "fee-ticket.burn": buildFeeTicketBurnPskt,
555
556
  "fee-ticket.transfer": buildFeeTicketTransferPskt,
557
+ "kcc20fee.create-account": buildFeeAccountCreatePskt,
558
+ "kcc20fee.bind-account": buildFeeAccountBindPskt,
556
559
  "kcc20wrapper.deploy-market": buildWrapperMarketDeployPskt,
557
560
  "kcc20wrapper.wrap": buildWrapperWrapPskt,
558
561
  "kcc20wrapper.unwrap": buildWrapperUnwrapPskt,
@@ -606,6 +609,295 @@ export function createKcc20PsktBuilderEngine(
606
609
  return builder(input);
607
610
  }
608
611
 
612
+ async function buildFeeAccountCreatePskt(input) {
613
+ const request = input.request || {};
614
+ const params = request.params || {};
615
+ const tokenId = requireHex32(params.tokenId, "fee account token id");
616
+ const owner = requireHex32(request.owner?.kcc20Owner, "fee account owner");
617
+ const principalSompi = parseU64(params.principalSompi, "fee account principal");
618
+ const network = request.network || env.KASPA_NETWORK || "testnet-10";
619
+ const walletAddress = requireKaspaAddress(request.owner?.walletAddress);
620
+ const priorityFee = parseU64(
621
+ env.KCC20_FEE_ACCOUNT_PRIORITY_FEE_SOMPI || DEFAULT_PRIORITY_FEE.toString(),
622
+ "priorityFee",
623
+ );
624
+ const artifactKey = "KCC20FeeEscrowV1.placeholder.json";
625
+ const supplied = options.artifacts?.[artifactKey] ?? await options.artifactProvider?.(artifactKey);
626
+ const artifact = supplied?.artifact ?? supplied;
627
+ if (!artifact) throw new Error("missing pinned fee escrow artifact");
628
+ const initial = buildKcc20InitialFeeAccount({
629
+ artifact, tokenId, ownerSchnorrPublicKey: owner, principalSompi,
630
+ });
631
+ const kw = kaspaWasm;
632
+ const { Encoding, GenesisCovenantGroup, RpcClient, Transaction,
633
+ TransactionOutput, covenantId, payToAddressScript, payToScriptHashScript } = kw;
634
+ const rpc = new RpcClient({
635
+ url: env.KASPA_WRPC_URL || DEFAULT_WRPC_URL,
636
+ encoding: Encoding.Borsh,
637
+ networkId: network,
638
+ });
639
+ await rpc.connect();
640
+ try {
641
+ const walletUtxos = await getUtxosByAddresses(rpc, [walletAddress]);
642
+ const fundingEntry = selectFundingEntry(
643
+ walletUtxos.entries,
644
+ principalSompi + priorityFee + 10_000n,
645
+ [],
646
+ params.fundingOutpoint,
647
+ );
648
+ const fundingAmount = utxoAmountSompi(fundingEntry);
649
+ const change = fundingAmount - principalSompi - priorityFee;
650
+ if (change <= 10_000n) throw new Error("fee account creation funding change is too small");
651
+ const transaction = new Transaction({
652
+ version: 1,
653
+ lockTime: 0n,
654
+ inputs: [{ previousOutpoint: fundingEntry.outpoint, utxo: fundingEntry,
655
+ sequence: 0n, sigOpCount: 0, computeBudget: 30 }],
656
+ outputs: [
657
+ new TransactionOutput(principalSompi, payToScriptHashScript(initial.redeemScript)),
658
+ new TransactionOutput(change, payToAddressScript(walletAddress)),
659
+ ],
660
+ subnetworkId: SUBNETWORK_ID_NATIVE,
661
+ gas: 0n,
662
+ payload: "",
663
+ });
664
+ setVersionOneInputMassFields(transaction, 30);
665
+ transaction.populateGenesisCovenants([new GenesisCovenantGroup(0, [0])]);
666
+ transaction.finalize();
667
+ const accountId = transaction.outputs[0]?.covenant?.covenantId?.toString();
668
+ if (!accountId) throw new Error("fee account genesis covenant id is missing");
669
+ const expectedId = covenantId(fundingEntry.outpoint, [
670
+ { index: 0, output: transaction.outputs[0] },
671
+ ]).toString();
672
+ if (accountId !== expectedId) throw new Error("fee account genesis covenant id mismatch");
673
+ const feeResult = transactionWithPredictedSignedFeeChange(
674
+ kw, transaction, 1, network, "fee account creation", candidate => {
675
+ const predicted = cloneTransactionForMassPreflight(kw, candidate, "fee account creation");
676
+ predicted.inputs[0].signatureScript = encodeP2pkSignatureScript(bytesToHex(dummySignatureBytes()));
677
+ return predicted;
678
+ },
679
+ );
680
+ const nonContextualMass = calculateToccataV1NonContextualMass(
681
+ feeResult.predictedTransaction, "fee account creation",
682
+ ).transactionMass;
683
+ const storageMass = calculateToccataStorageMass(
684
+ feeResult.predictedTransaction, "fee account creation",
685
+ );
686
+ if (nonContextualMass > CONSOLIDATION_TARGET_TRANSACTION_MASS ||
687
+ storageMass > CONSOLIDATION_TARGET_TRANSACTION_MASS) {
688
+ throw kcc20PsktBuilderError(
689
+ "fee account creation exceeds the signed transaction mass target",
690
+ "TRANSACTION_MASS_EXCEEDED",
691
+ { nonContextualMass, storageMass, targetMass: CONSOLIDATION_TARGET_TRANSACTION_MASS },
692
+ );
693
+ }
694
+ const finalAccountId = covenantId(fundingEntry.outpoint, [
695
+ { index: 0, output: feeResult.transaction.outputs[0] },
696
+ ]).toString();
697
+ if (finalAccountId !== accountId) throw new Error("fee account genesis id changed after fee calculation");
698
+ const psktTransactionJson = feeResult.transaction.serializeToSafeJSON();
699
+ if (typeof psktTransactionJson !== "string") throw new Error("fee account creation PSKT serialization failed");
700
+ return {
701
+ schema: OUTPUT_SCHEMA,
702
+ psktTransactionJson,
703
+ signInputs: [{ index: 0, sighashType: 1 }],
704
+ scripts: [],
705
+ submitTransactionSupported: true,
706
+ metadata: {
707
+ builderKey: "kcc20fee.create-account",
708
+ operation: "create-fee-account",
709
+ action: "genesis",
710
+ contract: "KCC20FeeEscrowV1",
711
+ network, walletAddress, tokenId, owner, accountId,
712
+ accountPrincipalSompi: principalSompi.toString(),
713
+ // Attach the broadcast txid to this hint before binding. The first
714
+ // spend reveals the script; until then the indexer sees only P2SH.
715
+ accountGenesisHint: {
716
+ vout: 0,
717
+ address: kw.addressFromScriptPublicKey(
718
+ payToScriptHashScript(initial.redeemScript), network,
719
+ ).toString(),
720
+ covenantIdHex: accountId,
721
+ scriptHashHex: initial.scriptHash,
722
+ amountSompiExact: principalSompi.toString(),
723
+ state: { ...initial.state, roleName: "account" },
724
+ },
725
+ },
726
+ };
727
+ } finally {
728
+ await rpc.disconnect();
729
+ }
730
+ }
731
+
732
+ async function buildFeeAccountBindPskt(input) {
733
+ const request = input.request || {};
734
+ const params = request.params || {};
735
+ // A genesis output has no revealed script yet. The caller keeps the
736
+ // creation hint, adds its accepted txid, then we check the live UTXO.
737
+ const indexed = params.accountGenesisHint
738
+ ? { ...params.accountGenesisHint, status: "active", spentByTxidHex: null }
739
+ : params.activeAccountUtxo;
740
+ if (!indexed || !indexed.address) throw new Error("active unbound fee account UTXO and address are required");
741
+ const tokenId = requireHex32(params.tokenId, "fee account token id");
742
+ const accountId = requireHex32(params.accountId, "fee account id");
743
+ const marketId = requireHex32(params.marketId, "fee account market id");
744
+ const policyId = requireHex32(params.policyId, "fee account policy id");
745
+ const owner = requireHex32(request.owner?.kcc20Owner, "fee account owner");
746
+ if (String(indexed.state?.owner || "").toLowerCase() !== owner) {
747
+ throw new Error("fee account owner does not match connected wallet");
748
+ }
749
+ const network = request.network || env.KASPA_NETWORK || "testnet-10";
750
+ const walletAddress = requireKaspaAddress(request.owner?.walletAddress);
751
+ const accountAddress = requireKaspaAddress(indexed.address);
752
+ const priorityFee = parseU64(
753
+ env.KCC20_FEE_ACCOUNT_PRIORITY_FEE_SOMPI || DEFAULT_PRIORITY_FEE.toString(),
754
+ "priorityFee",
755
+ );
756
+ const computeBudget = parsePositiveNumber(
757
+ env.KCC20_FEE_ACCOUNT_COMPUTE_BUDGET || 500,
758
+ "computeBudget",
759
+ );
760
+ const artifactKey = "KCC20FeeEscrowV1.placeholder.json";
761
+ const supplied = options.artifacts?.[artifactKey] ?? await options.artifactProvider?.(artifactKey);
762
+ const artifact = supplied?.artifact ?? supplied;
763
+ if (!artifact) throw new Error("missing pinned fee escrow artifact");
764
+
765
+ const kw = kaspaWasm;
766
+ const { CovenantBinding, Encoding, Hash, RpcClient, Transaction,
767
+ TransactionOutput, payToAddressScript, payToScriptHashScript } = kw;
768
+ const rpc = new RpcClient({
769
+ url: env.KASPA_WRPC_URL || DEFAULT_WRPC_URL,
770
+ encoding: Encoding.Borsh,
771
+ networkId: network,
772
+ });
773
+ await rpc.connect();
774
+ try {
775
+ const [accountUtxos, walletUtxos] = await Promise.all([
776
+ getUtxosByAddresses(rpc, [accountAddress]),
777
+ getUtxosByAddresses(rpc, [walletAddress]),
778
+ ]);
779
+ const txid = requireHex32(indexed.txidHex, "fee account txid");
780
+ const vout = parseVout(indexed.vout, "fee account vout");
781
+ const accountEntry = findUtxoEntry(accountUtxos.entries, txid, vout);
782
+ if (!accountEntry) throw new Error("unbound fee account UTXO is spent or unavailable");
783
+ requireMatchingCovenantId(accountEntry, accountId, "unbound fee account");
784
+ const spk = accountEntry.scriptPublicKey ??
785
+ accountEntry.entry?.scriptPublicKey ?? accountEntry.utxoEntry?.scriptPublicKey;
786
+ const scriptBytes = spk?.script;
787
+ if (!spk || !scriptBytes) throw new Error("unbound fee account has no script public key");
788
+ const scriptHex = typeof scriptBytes === "string" ? scriptBytes : bytesToHex(scriptBytes);
789
+ const plan = planKcc20FeeAccountBinding({
790
+ artifact,
791
+ indexed,
792
+ live: {
793
+ outpoint: { transactionId: txid, index: vout },
794
+ amountSompi: utxoAmountSompi(accountEntry),
795
+ covenantIdHex: accountId,
796
+ scriptPublicKey: { version: Number(spk.version), scriptHex },
797
+ },
798
+ tokenId, accountId, marketId, policyId,
799
+ });
800
+ assertScriptAddress(kw, plan.currentRedeemScript, accountAddress, network, "unbound fee account address");
801
+ const fundingEntry = selectFundingEntry(
802
+ walletUtxos.entries,
803
+ priorityFee + 10_000n,
804
+ [],
805
+ params.fundingOutpoint,
806
+ );
807
+ const fundingAmount = utxoAmountSompi(fundingEntry);
808
+ if (fundingAmount <= priorityFee + 10_000n) throw new Error("fee account binding funding change is too small");
809
+ const unsignedTx = new Transaction({
810
+ version: 1,
811
+ lockTime: 0n,
812
+ inputs: [
813
+ { previousOutpoint: accountEntry.outpoint, utxo: accountEntry,
814
+ sequence: 0n, sigOpCount: 0, computeBudget },
815
+ { previousOutpoint: fundingEntry.outpoint, utxo: fundingEntry,
816
+ sequence: 0n, sigOpCount: 0, computeBudget: 30 },
817
+ ],
818
+ outputs: [
819
+ new TransactionOutput(
820
+ plan.successorAmountSompi,
821
+ payToScriptHashScript(plan.successorRedeemScript),
822
+ new CovenantBinding(0, new Hash(accountId)),
823
+ ),
824
+ new TransactionOutput(
825
+ fundingAmount - priorityFee,
826
+ payToAddressScript(walletAddress),
827
+ ),
828
+ ],
829
+ subnetworkId: SUBNETWORK_ID_NATIVE,
830
+ gas: 0n,
831
+ payload: "",
832
+ });
833
+ setVersionOneInputMassFields(unsignedTx, computeBudget);
834
+ unsignedTx.inputs[1].computeBudget = 30;
835
+ const scripts = [{
836
+ inputIndex: 0,
837
+ scriptHex: bytesToHex(plan.currentRedeemScript),
838
+ signType: 1,
839
+ signatureScript: {
840
+ mode: "ordered-args",
841
+ args: [
842
+ dataTemplateArg(hexToBytes(marketId)),
843
+ dataTemplateArg(hexToBytes(policyId)),
844
+ { type: "signature" },
845
+ i64TemplateArg(0),
846
+ dataTemplateArg(dispatchTagFor(artifact, "bindMarket")),
847
+ ],
848
+ },
849
+ }];
850
+ const feeResult = transactionWithPredictedSignedFeeChange(
851
+ kw, unsignedTx, 1, network, "fee account binding", candidate => {
852
+ const serialized = candidate.serializeToSafeJSON();
853
+ if (typeof serialized !== "string") throw new Error("fee account mass preflight serialization failed");
854
+ const predictedJson = transactionJsonWithScriptHints(kw, JSON.parse(serialized), scripts);
855
+ predictedJson.inputs[1].signatureScript = encodeP2pkSignatureScript(
856
+ bytesToHex(dummySignatureBytes()),
857
+ );
858
+ return typeof kw.Transaction.deserializeFromSafeJSON === "function"
859
+ ? kw.Transaction.deserializeFromSafeJSON(JSON.stringify(predictedJson))
860
+ : kw.Transaction.deserializeFromJSON(JSON.stringify(predictedJson));
861
+ },
862
+ );
863
+ const nonContextualMass = calculateToccataV1NonContextualMass(
864
+ feeResult.predictedTransaction, "fee account binding",
865
+ ).transactionMass;
866
+ const storageMass = calculateToccataStorageMass(
867
+ feeResult.predictedTransaction, "fee account binding",
868
+ );
869
+ if (nonContextualMass > CONSOLIDATION_TARGET_TRANSACTION_MASS ||
870
+ storageMass > CONSOLIDATION_TARGET_TRANSACTION_MASS) {
871
+ throw kcc20PsktBuilderError(
872
+ "fee account binding exceeds the signed transaction mass target",
873
+ "TRANSACTION_MASS_EXCEEDED",
874
+ { nonContextualMass, storageMass, targetMass: CONSOLIDATION_TARGET_TRANSACTION_MASS },
875
+ );
876
+ }
877
+ const transaction = feeResult.transaction;
878
+ const psktTransactionJson = transaction.serializeToSafeJSON();
879
+ if (typeof psktTransactionJson !== "string") throw new Error("fee account binding PSKT serialization failed");
880
+ return {
881
+ schema: OUTPUT_SCHEMA,
882
+ psktTransactionJson,
883
+ signInputs: [{ index: 1, sighashType: 1 }],
884
+ scripts,
885
+ submitTransactionSupported: true,
886
+ metadata: {
887
+ builderKey: "kcc20fee.bind-account",
888
+ operation: "bind-fee-account",
889
+ action: "bindMarket",
890
+ contract: "KCC20FeeEscrowV1",
891
+ network, walletAddress, tokenId, accountId, marketId, policyId,
892
+ accountInput: `${txid}:${vout}`,
893
+ accountPrincipalSompi: plan.successorAmountSompi.toString(),
894
+ },
895
+ };
896
+ } finally {
897
+ await rpc.disconnect();
898
+ }
899
+ }
900
+
609
901
  async function buildFeeTicketRootUpdatePskt(input) {
610
902
  const request = input.request || {};
611
903
  const params = request.params || {};
@@ -0,0 +1,124 @@
1
+ import { normalizeSilverScriptArtifact, templateParts } from "./abi.js";
2
+ import { KCC20_ESCROW_MAX_INT } from "./escrow-fee.js";
3
+ import type { Kcc20FeeAccountState } from "./escrow-claim-plan.js";
4
+ import {
5
+ buildKcc20FeeEscrowScript,
6
+ verifyKcc20UnboundFeeAccountForBinding,
7
+ type Kcc20IndexedFeeReceipt,
8
+ type Kcc20LiveFeeReceiptUtxo,
9
+ } from "./escrow-receipt.js";
10
+
11
+ const ZERO = "00".repeat(32);
12
+ const SECP256K1_FIELD = (1n << 256n) - (1n << 32n) - 977n;
13
+
14
+ function hex32(value: string, label: string): string {
15
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/i.test(value)) {
16
+ throw new Error(`${label} must be 32-byte hex`);
17
+ }
18
+ return value.toLowerCase();
19
+ }
20
+
21
+ function principal(value: bigint): bigint {
22
+ if (typeof value !== "bigint" || value <= 0n || value > KCC20_ESCROW_MAX_INT) {
23
+ throw new Error("fee account principal must be a positive contract-range bigint");
24
+ }
25
+ return value;
26
+ }
27
+
28
+ function modPow(base: bigint, exponent: bigint): bigint {
29
+ let result = 1n;
30
+ let factor = base % SECP256K1_FIELD;
31
+ for (let remaining = exponent; remaining > 0n; remaining >>= 1n) {
32
+ if (remaining & 1n) result = (result * factor) % SECP256K1_FIELD;
33
+ factor = (factor * factor) % SECP256K1_FIELD;
34
+ }
35
+ return result;
36
+ }
37
+
38
+ /** BIP340 keys encode an x-coordinate that must lift to a secp256k1 point. */
39
+ function requireSchnorrXOnlyPublicKey(value: string): string {
40
+ const key = hex32(value, "owner Schnorr public key");
41
+ const x = BigInt(`0x${key}`);
42
+ if (x >= SECP256K1_FIELD) throw new Error("owner Schnorr public key is not a curve point");
43
+ const square = (x * x % SECP256K1_FIELD * x + 7n) % SECP256K1_FIELD;
44
+ const y = modPow(square, (SECP256K1_FIELD + 1n) / 4n);
45
+ if (y * y % SECP256K1_FIELD !== square) {
46
+ throw new Error("owner Schnorr public key is not a curve point");
47
+ }
48
+ return key;
49
+ }
50
+
51
+ /**
52
+ * Construct the initial account output. Its covenant lineage is assigned by
53
+ * the transaction; the market can bind that lineage only after it is known.
54
+ * This function does not select funding, sign, or estimate transaction mass.
55
+ */
56
+ export function buildKcc20InitialFeeAccount(input: {
57
+ artifact: unknown;
58
+ tokenId: string;
59
+ ownerSchnorrPublicKey: string;
60
+ principalSompi: bigint;
61
+ }) {
62
+ const token = hex32(input.tokenId, "token");
63
+ const owner = requireSchnorrXOnlyPublicKey(input.ownerSchnorrPublicKey);
64
+ if (token === ZERO || owner === ZERO) throw new Error("fee account token and owner must be nonzero");
65
+ const amountSompi = principal(input.principalSompi);
66
+ const template = templateParts(normalizeSilverScriptArtifact(input.artifact));
67
+ const state: Kcc20FeeAccountState = {
68
+ role: "1", token, market: ZERO, policy: ZERO,
69
+ protocol_account: ZERO, creator_account: ZERO,
70
+ protocol_amount: "0", creator_amount: "0", owner,
71
+ owner_scheme: 0, guard_template: ZERO, guard_prefix: "0", guard_suffix: "0",
72
+ action_hash: ZERO,
73
+ self_template: toHex(template.hash),
74
+ self_prefix: String(template.prefixLength), self_suffix: String(template.suffixLength),
75
+ };
76
+ return { state, amountSompi, ...buildKcc20FeeEscrowScript(input.artifact, state as unknown as Record<string, unknown>) };
77
+ }
78
+
79
+ function toHex(bytes: Uint8Array): string {
80
+ return Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
81
+ }
82
+
83
+ /**
84
+ * Verify the current unbound account output and plan the exact continuation.
85
+ * The owner signs bindMarket once; later claims use the account's authority.
86
+ */
87
+ export function planKcc20FeeAccountBinding(input: {
88
+ artifact: unknown;
89
+ indexed: Kcc20IndexedFeeReceipt;
90
+ live: Kcc20LiveFeeReceiptUtxo;
91
+ tokenId: string;
92
+ accountId: string;
93
+ marketId: string;
94
+ policyId: string;
95
+ }) {
96
+ const market = hex32(input.marketId, "market");
97
+ const policy = hex32(input.policyId, "policy");
98
+ if (market === ZERO || policy === ZERO) throw new Error("fee account binding requires a market and policy");
99
+ const verified = verifyKcc20UnboundFeeAccountForBinding({
100
+ artifact: input.artifact,
101
+ indexed: input.indexed,
102
+ live: input.live,
103
+ expected: { tokenId: input.tokenId, accountId: input.accountId },
104
+ });
105
+ const successorState: Kcc20FeeAccountState = {
106
+ ...verified.account.state,
107
+ market, policy,
108
+ };
109
+ const successor = buildKcc20FeeEscrowScript(
110
+ input.artifact,
111
+ successorState as unknown as Record<string, unknown>,
112
+ );
113
+ return {
114
+ entrypoint: "bindMarket" as const,
115
+ account: verified.account,
116
+ currentRedeemScript: verified.redeemScript,
117
+ successorState,
118
+ successorRedeemScript: successor.redeemScript,
119
+ successorScriptPublicKey: successor.scriptPublicKey,
120
+ successorAmountSompi: verified.account.principalSompi,
121
+ requiresOwnerSchnorrSignature: true as const,
122
+ requiresSeparateNetworkFunding: true as const,
123
+ };
124
+ }
@@ -0,0 +1,130 @@
1
+ import { calculateKcc20FeeClaim, KCC20_ESCROW_MAX_INT, type Kcc20FeeReceiptAmounts } from "./escrow-fee.js";
2
+ import { kcc20FeeClaimCommitment } from "./escrow-policy.js";
3
+
4
+ const ZERO = "00".repeat(32);
5
+ const HEX_32 = /^[0-9a-f]{64}$/i;
6
+
7
+ export interface Kcc20FeeAccountState {
8
+ role: string;
9
+ token: string;
10
+ market: string;
11
+ policy: string;
12
+ protocol_account: string;
13
+ creator_account: string;
14
+ protocol_amount: string;
15
+ creator_amount: string;
16
+ owner: string;
17
+ owner_scheme: number;
18
+ guard_template: string;
19
+ guard_prefix: string;
20
+ guard_suffix: string;
21
+ action_hash: string;
22
+ self_template: string;
23
+ self_prefix: string;
24
+ self_suffix: string;
25
+ }
26
+
27
+ export interface Kcc20FeeClaimAccount {
28
+ outpoint: { transactionId: string; index: number };
29
+ covenantId: string;
30
+ principalSompi: bigint;
31
+ state: Kcc20FeeAccountState;
32
+ }
33
+
34
+ function hex32(value: string, label: string): string {
35
+ if (typeof value !== "string" || !HEX_32.test(value)) throw new Error(`${label} must be 32-byte hex`);
36
+ return value.toLowerCase();
37
+ }
38
+
39
+ function natural(value: string, label: string): bigint {
40
+ if (typeof value !== "string" || !/^(0|[1-9]\d*)$/.test(value)) throw new Error(`${label} must be canonical decimal`);
41
+ const result = BigInt(value);
42
+ if (result > KCC20_ESCROW_MAX_INT) throw new Error(`${label} exceeds the contract range`);
43
+ return result;
44
+ }
45
+
46
+ /**
47
+ * Derive the exact claim amounts and covenant successor states. Inputs must
48
+ * already be authenticated against current node UTXOs. This plan does not
49
+ * authorize account ownership, sign, estimate mass or select network funding.
50
+ */
51
+ export function planKcc20FeeClaimTransition(input: {
52
+ receipts: readonly Kcc20FeeReceiptAmounts[];
53
+ party: "protocol" | "creator";
54
+ account: Kcc20FeeClaimAccount;
55
+ /** Supplied by a successful pinned receipt verification, not by an indexer hint alone. */
56
+ escrowTemplate: { hash: string; prefixLength: number; suffixLength: number };
57
+ /** Serialized ScriptPublicKey including its two-byte little-endian version. */
58
+ payoutScriptPublicKey: Uint8Array;
59
+ }) {
60
+ const amounts = calculateKcc20FeeClaim(input.receipts, input.party);
61
+ const first = input.receipts[0]!;
62
+ const account = input.account;
63
+ const state = account.state;
64
+ const targetAccount = input.party === "protocol" ? first.protocolAccountId : first.creatorAccountId;
65
+ if (hex32(targetAccount, "target account") === ZERO ||
66
+ hex32(account.covenantId, "account covenant") !== hex32(targetAccount, "target account")) {
67
+ throw new Error("claim uses the wrong fee account lineage");
68
+ }
69
+ if (!Number.isInteger(account.outpoint.index) || account.outpoint.index < 0 || account.outpoint.index > 0xffffffff ||
70
+ hex32(account.outpoint.transactionId, "account txid") === ZERO ||
71
+ typeof account.principalSompi !== "bigint" || account.principalSompi <= 0n ||
72
+ account.principalSompi > KCC20_ESCROW_MAX_INT) {
73
+ throw new Error("invalid fee account outpoint or principal");
74
+ }
75
+ if (state.role !== "1" || hex32(state.token, "account token") !== hex32(first.tokenId, "receipt token") ||
76
+ hex32(state.market, "account market") !== hex32(first.marketId, "receipt market") ||
77
+ hex32(state.policy, "account policy") !== hex32(first.policyId, "receipt policy") ||
78
+ [state.protocol_account, state.creator_account, state.owner].some(value => !HEX_32.test(value)) ||
79
+ state.protocol_account.toLowerCase() !== ZERO || state.creator_account.toLowerCase() !== ZERO ||
80
+ state.owner.toLowerCase() === ZERO || state.protocol_amount !== "0" || state.creator_amount !== "0") {
81
+ throw new Error("invalid fee account state");
82
+ }
83
+ if (!Number.isInteger(state.owner_scheme) || state.owner_scheme < 0 || state.owner_scheme > 4 ||
84
+ !HEX_32.test(state.guard_template) || !HEX_32.test(state.action_hash) || !HEX_32.test(state.self_template)) {
85
+ throw new Error("unsupported fee account authority state");
86
+ }
87
+ const guardPrefix = natural(state.guard_prefix, "guard prefix");
88
+ const guardSuffix = natural(state.guard_suffix, "guard suffix");
89
+ const selfPrefix = natural(state.self_prefix, "self prefix");
90
+ const selfSuffix = natural(state.self_suffix, "self suffix");
91
+ if (!Number.isSafeInteger(input.escrowTemplate.prefixLength) ||
92
+ !Number.isSafeInteger(input.escrowTemplate.suffixLength) ||
93
+ input.escrowTemplate.prefixLength <= 0 || input.escrowTemplate.suffixLength <= 0 ||
94
+ state.self_template.toLowerCase() !== hex32(input.escrowTemplate.hash, "escrow template") ||
95
+ selfPrefix !== BigInt(input.escrowTemplate.prefixLength) ||
96
+ selfSuffix !== BigInt(input.escrowTemplate.suffixLength) ||
97
+ (state.owner_scheme === 3 && (state.guard_template.toLowerCase() === ZERO || guardPrefix !== 0n || guardSuffix !== 0n)) ||
98
+ (state.owner_scheme === 4 && (state.guard_template.toLowerCase() === ZERO || guardPrefix === 0n || guardSuffix === 0n)) ||
99
+ (state.owner_scheme <= 2 && (state.guard_template.toLowerCase() !== ZERO || guardPrefix !== 0n || guardSuffix !== 0n))) {
100
+ throw new Error("invalid fee account guard state");
101
+ }
102
+ const commitment = kcc20FeeClaimCommitment({
103
+ receipts: input.receipts,
104
+ party: input.party,
105
+ accountOutpoint: account.outpoint,
106
+ payoutScriptPublicKey: input.payoutScriptPublicKey,
107
+ });
108
+ const successorAccountState: Kcc20FeeAccountState = { ...state, action_hash: commitment };
109
+ const remainderReceiptState = amounts.remainderSompi > 0n ? {
110
+ role: "0", token: first.tokenId, market: first.marketId, policy: first.policyId,
111
+ protocol_account: first.protocolAccountId, creator_account: first.creatorAccountId,
112
+ protocol_amount: amounts.remainingProtocolSompi.toString(),
113
+ creator_amount: amounts.remainingCreatorSompi.toString(),
114
+ owner: ZERO, owner_scheme: 0, guard_template: ZERO,
115
+ guard_prefix: "0", guard_suffix: "0", action_hash: ZERO,
116
+ self_template: input.escrowTemplate.hash.toLowerCase(),
117
+ self_prefix: String(input.escrowTemplate.prefixLength),
118
+ self_suffix: String(input.escrowTemplate.suffixLength),
119
+ } : null;
120
+ return {
121
+ party: input.party,
122
+ claimSompi: amounts.claimSompi,
123
+ accountPrincipalSompi: account.principalSompi,
124
+ remainderSompi: amounts.remainderSompi,
125
+ commitment,
126
+ successorAccountState,
127
+ remainderReceiptState,
128
+ requiresSeparateNetworkFunding: true as const,
129
+ };
130
+ }