@hardkas/tx-builder 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Rodriguez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ interface MassBreakdown {
2
+ base: bigint;
3
+ inputs: bigint;
4
+ outputs: bigint;
5
+ payload: bigint;
6
+ total: bigint;
7
+ }
8
+ interface MassEstimateResult {
9
+ mass: bigint;
10
+ feeSompi: bigint;
11
+ breakdown: MassBreakdown;
12
+ assumptions: string[];
13
+ warnings: string[];
14
+ }
15
+ /**
16
+ * Protocol-aware mass estimation constants for Kaspa (approximate).
17
+ * These values align with standard P2PK/Schnorr transactions.
18
+ */
19
+ declare const KASPA_MASS_CONSTANTS: {
20
+ /** Base transaction overhead (version, locktime, subnetwork id, gas, etc.) */
21
+ readonly BASE_TRANSACTION: 100n;
22
+ /** Mass per input (Outpoint + Sequence + SigScript + SigOpCount) */
23
+ readonly INPUT_P2PK: 150n;
24
+ /** Mass per output (Amount + ScriptPublicKey length prefix + ScriptPublicKey) */
25
+ readonly OUTPUT_P2PK: 50n;
26
+ /** Fallback mass for unknown script types (P2SH, etc.) */
27
+ readonly SCRIPT_FALLBACK: 150n;
28
+ /** Mass per byte of payload */
29
+ readonly PAYLOAD_BYTE: 1n;
30
+ };
31
+ /**
32
+ * Estimates the mass of a transaction based on its structure and script types.
33
+ *
34
+ * Note: alpha mass estimation is protocol-aware but still validated
35
+ * as best-effort until parity tests with kaspad/rusty-kaspa are complete.
36
+ */
37
+ declare function estimateTransactionMass(input: {
38
+ inputCount: number;
39
+ outputs: readonly {
40
+ address: string;
41
+ scriptPublicKey?: string;
42
+ }[];
43
+ payloadBytes?: number;
44
+ hasChange?: boolean;
45
+ }): MassEstimateResult;
46
+ /**
47
+ * Calculates fee from mass and fee rate.
48
+ */
49
+ declare function estimateFeeFromMass(mass: bigint, feeRateSompiPerMass: bigint): bigint;
50
+
51
+ type SemanticVerificationSeverity = "info" | "warning" | "error" | "critical";
52
+ interface SemanticVerificationIssue {
53
+ code: string;
54
+ severity: SemanticVerificationSeverity;
55
+ message: string;
56
+ path?: string | undefined;
57
+ }
58
+ interface SemanticVerificationResult {
59
+ ok: boolean;
60
+ issues: SemanticVerificationIssue[];
61
+ recomputedFeeSompi: bigint;
62
+ recomputedMass: bigint;
63
+ inputTotalSompi: bigint;
64
+ outputTotalSompi: bigint;
65
+ changeAmountSompi: bigint;
66
+ }
67
+ interface SemanticVerifyContext {
68
+ /** Known UTXOs to verify lineage against */
69
+ utxoContext?: {
70
+ address: string;
71
+ amountSompi: bigint;
72
+ outpoint: {
73
+ transactionId: string;
74
+ index: number;
75
+ };
76
+ }[];
77
+ /** Expected change address */
78
+ expectedChangeAddress?: string;
79
+ /** Minimum fee rate required by the node */
80
+ minFeeRate?: bigint;
81
+ }
82
+ /**
83
+ * Performs deep semantic verification of a transaction plan.
84
+ * Validates economic invariants, mass computation, and operational consistency.
85
+ */
86
+ declare function verifyTxPlanSemantics(plan: TxPlan, context?: SemanticVerifyContext): SemanticVerificationResult;
87
+ /**
88
+ * Performs semantic verification of a signed transaction artifact.
89
+ */
90
+ declare function verifySignedTxSemantics(signed: any, // Using any for artifact structure compatibility without circular deps
91
+ plan?: TxPlan): {
92
+ ok: boolean;
93
+ issues: SemanticVerificationIssue[];
94
+ };
95
+ /**
96
+ * Performs semantic verification of a transaction receipt artifact.
97
+ */
98
+ declare function verifyTxReceiptSemantics(receipt: any): {
99
+ ok: boolean;
100
+ issues: SemanticVerificationIssue[];
101
+ };
102
+
103
+ type Sompi = bigint;
104
+
105
+ interface Outpoint {
106
+ readonly transactionId: string;
107
+ readonly index: number;
108
+ }
109
+ interface Utxo {
110
+ readonly outpoint: Outpoint;
111
+ readonly address: string;
112
+ readonly amountSompi: Sompi;
113
+ readonly scriptPublicKey: string;
114
+ readonly blockDaaScore?: bigint;
115
+ readonly isCoinbase?: boolean;
116
+ }
117
+ interface TxOutput {
118
+ readonly address: string;
119
+ readonly amountSompi: Sompi;
120
+ /** Future: Silverscript / custom script support */
121
+ readonly scriptPublicKey?: string;
122
+ }
123
+ interface TxBuildRequest {
124
+ readonly fromAddress: string;
125
+ readonly outputs: readonly TxOutput[];
126
+ readonly availableUtxos: readonly Utxo[];
127
+ readonly feeRateSompiPerMass: bigint;
128
+ readonly changeAddress?: string;
129
+ readonly payloadBytes?: number;
130
+ }
131
+ interface TxPlan {
132
+ readonly inputs: readonly Utxo[];
133
+ readonly outputs: readonly TxOutput[];
134
+ readonly change?: TxOutput | undefined;
135
+ readonly estimatedMass: bigint;
136
+ readonly estimatedFeeSompi: bigint;
137
+ }
138
+ declare function buildPaymentPlan(request: TxBuildRequest): TxPlan;
139
+ declare function estimateMass(input: {
140
+ readonly inputCount: number;
141
+ readonly outputCount: number;
142
+ readonly payloadBytes: number;
143
+ }): bigint;
144
+ declare function createMockUtxo(input: {
145
+ readonly address: string;
146
+ readonly amountSompi: bigint;
147
+ readonly index?: number;
148
+ }): Utxo;
149
+
150
+ export { KASPA_MASS_CONSTANTS, type MassBreakdown, type MassEstimateResult, type Outpoint, type SemanticVerificationIssue, type SemanticVerificationResult, type SemanticVerificationSeverity, type SemanticVerifyContext, type Sompi, type TxBuildRequest, type TxOutput, type TxPlan, type Utxo, buildPaymentPlan, createMockUtxo, estimateFeeFromMass, estimateMass, estimateTransactionMass, verifySignedTxSemantics, verifyTxPlanSemantics, verifyTxReceiptSemantics };
package/dist/index.js ADDED
@@ -0,0 +1,280 @@
1
+ // src/mass.ts
2
+ var KASPA_MASS_CONSTANTS = {
3
+ /** Base transaction overhead (version, locktime, subnetwork id, gas, etc.) */
4
+ BASE_TRANSACTION: 100n,
5
+ /** Mass per input (Outpoint + Sequence + SigScript + SigOpCount) */
6
+ INPUT_P2PK: 150n,
7
+ /** Mass per output (Amount + ScriptPublicKey length prefix + ScriptPublicKey) */
8
+ OUTPUT_P2PK: 50n,
9
+ /** Fallback mass for unknown script types (P2SH, etc.) */
10
+ SCRIPT_FALLBACK: 150n,
11
+ /** Mass per byte of payload */
12
+ PAYLOAD_BYTE: 1n
13
+ };
14
+ function estimateTransactionMass(input) {
15
+ const assumptions = [];
16
+ const warnings = [];
17
+ const base = KASPA_MASS_CONSTANTS.BASE_TRANSACTION;
18
+ const inputs = BigInt(input.inputCount) * KASPA_MASS_CONSTANTS.INPUT_P2PK;
19
+ assumptions.push(`Inputs assumed P2PK/Schnorr (${input.inputCount})`);
20
+ let outputs = 0n;
21
+ for (const out of input.outputs) {
22
+ if (isP2PK(out.scriptPublicKey || out.address)) {
23
+ outputs += KASPA_MASS_CONSTANTS.OUTPUT_P2PK;
24
+ } else {
25
+ outputs += KASPA_MASS_CONSTANTS.SCRIPT_FALLBACK;
26
+ warnings.push(`P2SH/Other script detected for address: ${out.address}. Mass is estimated using placeholder script-size assumptions.`);
27
+ }
28
+ }
29
+ if (input.hasChange) {
30
+ outputs += KASPA_MASS_CONSTANTS.OUTPUT_P2PK;
31
+ }
32
+ const payload = BigInt(input.payloadBytes || 0) * KASPA_MASS_CONSTANTS.PAYLOAD_BYTE;
33
+ const total = base + inputs + outputs + payload;
34
+ return {
35
+ mass: total,
36
+ feeSompi: 0n,
37
+ // Placeholder, calculated by caller
38
+ breakdown: {
39
+ base,
40
+ inputs,
41
+ outputs,
42
+ payload,
43
+ total
44
+ },
45
+ assumptions,
46
+ warnings
47
+ };
48
+ }
49
+ function isP2PK(addressOrScript) {
50
+ if (/^[0-9a-fA-F]+$/.test(addressOrScript)) {
51
+ return addressOrScript.length === 68;
52
+ }
53
+ if (addressOrScript.includes(":")) {
54
+ const parts = addressOrScript.split(":");
55
+ const body = parts[1];
56
+ return !!body && (body.startsWith("q") || body.startsWith("sim_"));
57
+ }
58
+ return true;
59
+ }
60
+ function estimateFeeFromMass(mass, feeRateSompiPerMass) {
61
+ return mass * feeRateSompiPerMass;
62
+ }
63
+
64
+ // src/verify.ts
65
+ function verifyTxPlanSemantics(plan, context = {}) {
66
+ const issues = [];
67
+ const addIssue = (code, severity, message, path) => {
68
+ issues.push({ code, severity, message, ...path ? { path } : {} });
69
+ };
70
+ if (plan.mode === "simulated" && plan.networkId !== "simnet") {
71
+ addIssue("ENV_CONSISTENCY_FAILURE", "error", `Environment mismatch: simulated plan must target 'simnet', but targets '${plan.networkId}'`);
72
+ }
73
+ if (!plan.inputs.every((i) => i.address.includes(":"))) {
74
+ addIssue("INVALID_ADDRESS_FORMAT", "error", "One or more input addresses are missing prefix (e.g. kaspa:)");
75
+ }
76
+ if (!plan.outputs.every((o) => o.address.includes(":"))) {
77
+ addIssue("INVALID_ADDRESS_FORMAT", "error", "One or more output addresses are missing prefix (e.g. kaspa:)");
78
+ }
79
+ const inputTotalSompi = plan.inputs.reduce((sum, i) => sum + BigInt(i.amountSompi), 0n);
80
+ const outputTotalSompi = plan.outputs.reduce((sum, o) => sum + BigInt(o.amountSompi), 0n);
81
+ const changeAmountSompi = plan.change ? BigInt(plan.change.amountSompi) : 0n;
82
+ const planFeeSompi = BigInt(plan.estimatedFeeSompi);
83
+ const recomputedFeeSompi = inputTotalSompi - outputTotalSompi - changeAmountSompi;
84
+ if (inputTotalSompi <= 0n) {
85
+ addIssue("ZERO_INPUTS", "critical", "Transaction has zero or negative total inputs.");
86
+ }
87
+ if (outputTotalSompi <= 0n) {
88
+ addIssue("ZERO_OUTPUTS", "error", "Transaction has zero or negative total outputs (excluding change).");
89
+ }
90
+ if (recomputedFeeSompi < 0n) {
91
+ addIssue("NEGATIVE_FEE", "critical", `Negative fee detected: inputs (${inputTotalSompi}) < outputs + change (${outputTotalSompi + changeAmountSompi})`);
92
+ }
93
+ const massResult = estimateTransactionMass({
94
+ inputCount: plan.inputs.length,
95
+ outputs: plan.outputs,
96
+ hasChange: !!plan.change
97
+ });
98
+ const recomputedMass = massResult.mass;
99
+ if (recomputedMass !== BigInt(plan.estimatedMass)) {
100
+ addIssue("MASS_MISMATCH", "critical", `Mass mismatch: plan says ${plan.estimatedMass}, recomputed ${recomputedMass}`);
101
+ }
102
+ if (planFeeSompi !== recomputedFeeSompi) {
103
+ addIssue("FEE_MISMATCH", "critical", `Fee mismatch: estimatedFeeSompi (${planFeeSompi}) does not match input-output delta (${recomputedFeeSompi})`);
104
+ }
105
+ plan.outputs.forEach((o, i) => {
106
+ if (BigInt(o.amountSompi) <= 0n) {
107
+ addIssue("INVALID_OUTPUT_AMOUNT", "error", `Output ${i} has non-positive amount: ${o.amountSompi}`, `outputs[${i}]`);
108
+ }
109
+ if (BigInt(o.amountSompi) < 600n) {
110
+ addIssue("DUST_OUTPUT", "warning", `Output ${i} might be dust: ${o.amountSompi} sompi`, `outputs[${i}]`);
111
+ }
112
+ });
113
+ if (plan.change && BigInt(plan.change.amountSompi) < 600n) {
114
+ addIssue("DUST_CHANGE", "warning", `Change output might be dust: ${plan.change.amountSompi} sompi`, "change");
115
+ }
116
+ const seenInputs = /* @__PURE__ */ new Set();
117
+ plan.inputs.forEach((input, i) => {
118
+ const id = `${input.outpoint.transactionId}:${input.outpoint.index}`;
119
+ if (seenInputs.has(id)) {
120
+ addIssue("DUPLICATE_INPUT", "critical", `Duplicate input detected: ${id}`, `inputs[${i}]`);
121
+ }
122
+ seenInputs.add(id);
123
+ });
124
+ if (context.utxoContext) {
125
+ plan.inputs.forEach((input, i) => {
126
+ const match = context.utxoContext?.find(
127
+ (u) => u.outpoint.transactionId === input.outpoint.transactionId && u.outpoint.index === input.outpoint.index
128
+ );
129
+ if (!match) {
130
+ addIssue("UNKNOWN_INPUT", "error", `Input ${i} not found in provided UTXO context`, `inputs[${i}]`);
131
+ } else if (BigInt(match.amountSompi) !== BigInt(input.amountSompi)) {
132
+ addIssue("INPUT_AMOUNT_MISMATCH", "critical", `Input ${i} amount mismatch: plan says ${input.amountSompi}, context says ${match.amountSompi}`, `inputs[${i}]`);
133
+ }
134
+ });
135
+ }
136
+ if (context.expectedChangeAddress && plan.change) {
137
+ if (plan.change.address !== context.expectedChangeAddress) {
138
+ addIssue("CHANGE_ADDRESS_MISMATCH", "error", `Change address mismatch: expected ${context.expectedChangeAddress}, got ${plan.change.address}`, "change.address");
139
+ }
140
+ }
141
+ return {
142
+ ok: issues.every((i) => i.severity !== "error" && i.severity !== "critical"),
143
+ issues,
144
+ recomputedFeeSompi,
145
+ recomputedMass,
146
+ inputTotalSompi,
147
+ outputTotalSompi,
148
+ changeAmountSompi
149
+ };
150
+ }
151
+ function verifySignedTxSemantics(signed, plan) {
152
+ const issues = [];
153
+ const addIssue = (code, severity, message) => {
154
+ issues.push({ code, severity, message });
155
+ };
156
+ if (plan) {
157
+ if (signed.sourcePlanId !== plan.planId && signed.sourcePlanId !== plan.contentHash) {
158
+ }
159
+ if (BigInt(signed.amountSompi) !== BigInt(plan.amountSompi)) {
160
+ addIssue("IMMUTABLE_FIELD_MUTATION", "critical", `Security violation: amountSompi changed from ${plan.amountSompi} to ${signed.amountSompi} after signing`);
161
+ }
162
+ if (signed.networkId !== plan.networkId) {
163
+ addIssue("NETWORK_MISMATCH", "critical", `Security violation: networkId changed from ${plan.networkId} to ${signed.networkId} after signing`);
164
+ }
165
+ }
166
+ if (!signed.signedTransaction?.payload) {
167
+ addIssue("MISSING_PAYLOAD", "error", "Signed transaction is missing its raw payload");
168
+ }
169
+ return {
170
+ ok: issues.every((i) => i.severity !== "error" && i.severity !== "critical"),
171
+ issues
172
+ };
173
+ }
174
+ function verifyTxReceiptSemantics(receipt) {
175
+ const issues = [];
176
+ const addIssue = (code, severity, message) => {
177
+ issues.push({ code, severity, message });
178
+ };
179
+ if (receipt.status === "accepted" && !receipt.txId) {
180
+ addIssue("MISSING_TXID", "error", "Accepted receipt is missing transaction ID");
181
+ }
182
+ if (receipt.mode === "simulated" && !receipt.tracePath) {
183
+ addIssue("MISSING_TRACE", "warning", "Simulated receipt is missing trace path");
184
+ }
185
+ return {
186
+ ok: issues.every((i) => i.severity !== "error" && i.severity !== "critical"),
187
+ issues
188
+ };
189
+ }
190
+
191
+ // src/index.ts
192
+ function buildPaymentPlan(request) {
193
+ if (request.outputs.length === 0) {
194
+ throw new Error("At least one transaction output is required.");
195
+ }
196
+ const target = request.outputs.reduce(
197
+ (sum, output) => sum + output.amountSompi,
198
+ 0n
199
+ );
200
+ if (target <= 0n) {
201
+ throw new Error("Transaction amount must be positive.");
202
+ }
203
+ const sortedUtxos = [...request.availableUtxos].sort(
204
+ (a, b) => a.amountSompi < b.amountSompi ? -1 : a.amountSompi > b.amountSompi ? 1 : 0
205
+ );
206
+ const selected = [];
207
+ let selectedAmount = 0n;
208
+ for (const utxo of sortedUtxos) {
209
+ selected.push(utxo);
210
+ selectedAmount += utxo.amountSompi;
211
+ if (selectedAmount < target) continue;
212
+ const result = estimateTransactionMass({
213
+ inputCount: selected.length,
214
+ outputs: request.outputs,
215
+ payloadBytes: request.payloadBytes ?? 0,
216
+ hasChange: true
217
+ // Optimistic assumption for the loop
218
+ });
219
+ const estimatedMass = result.mass;
220
+ const estimatedFeeSompi = estimatedMass * request.feeRateSompiPerMass;
221
+ if (selectedAmount >= target + estimatedFeeSompi) {
222
+ const changeAmount = selectedAmount - target - estimatedFeeSompi;
223
+ const hasActualChange = changeAmount > 0n;
224
+ let finalMass = estimatedMass;
225
+ let finalFee = estimatedFeeSompi;
226
+ if (!hasActualChange) {
227
+ const noChangeResult = estimateTransactionMass({
228
+ inputCount: selected.length,
229
+ outputs: request.outputs,
230
+ payloadBytes: request.payloadBytes ?? 0,
231
+ hasChange: false
232
+ });
233
+ finalMass = noChangeResult.mass;
234
+ finalFee = finalMass * request.feeRateSompiPerMass;
235
+ if (selectedAmount < target + finalFee) continue;
236
+ }
237
+ return {
238
+ inputs: selected,
239
+ outputs: request.outputs,
240
+ change: hasActualChange ? {
241
+ address: request.changeAddress ?? request.fromAddress,
242
+ amountSompi: changeAmount
243
+ } : void 0,
244
+ estimatedMass: finalMass,
245
+ estimatedFeeSompi: finalFee
246
+ };
247
+ }
248
+ }
249
+ throw new Error("Insufficient funds for transaction amount plus estimated fee.");
250
+ }
251
+ function estimateMass(input) {
252
+ return estimateTransactionMass({
253
+ inputCount: input.inputCount,
254
+ outputs: Array(input.outputCount - 1).fill({ address: "" }),
255
+ payloadBytes: input.payloadBytes,
256
+ hasChange: true
257
+ }).mass;
258
+ }
259
+ function createMockUtxo(input) {
260
+ return {
261
+ outpoint: {
262
+ transactionId: `mock-${input.address}-${input.index ?? 0}`,
263
+ index: input.index ?? 0
264
+ },
265
+ address: input.address,
266
+ amountSompi: input.amountSompi,
267
+ scriptPublicKey: "mock-script"
268
+ };
269
+ }
270
+ export {
271
+ KASPA_MASS_CONSTANTS,
272
+ buildPaymentPlan,
273
+ createMockUtxo,
274
+ estimateFeeFromMass,
275
+ estimateMass,
276
+ estimateTransactionMass,
277
+ verifySignedTxSemantics,
278
+ verifyTxPlanSemantics,
279
+ verifyTxReceiptSemantics
280
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@hardkas/tx-builder",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@hardkas/core": "0.1.0"
12
+ },
13
+ "devDependencies": {
14
+ "tsup": "^8.3.5",
15
+ "typescript": "^5.7.2",
16
+ "vitest": "^2.1.8"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Javier Rodriguez",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/jrodrg92/Hardkas.git",
23
+ "directory": "packages/tx-builder"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/jrodrg92/Hardkas/issues"
27
+ },
28
+ "homepage": "https://github.com/jrodrg92/Hardkas/tree/main/packages/tx-builder#readme",
29
+ "files": [
30
+ "dist",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsup src/index.ts --format esm --dts --clean",
36
+ "test": "vitest run",
37
+ "typecheck": "tsc --noEmit",
38
+ "lint": "eslint ."
39
+ }
40
+ }