@infantmen-labs/harbor-sdk 0.4.3 → 0.5.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/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # harbor-sdk
2
+
3
+ TypeScript SDK for Harbor bonded refunds on Solana: PDA helpers,
4
+ instruction builders, receipt codec, ed25519 verify, account decoders.
5
+
6
+ ```sh
7
+ npm i @infantmen-labs/harbor-sdk
8
+ ```
9
+
10
+ ```ts
11
+ import {
12
+ bondPda,
13
+ decodeBond,
14
+ receiptMessageBytes,
15
+ verifyEd25519,
16
+ } from "@infantmen-labs/harbor-sdk";
17
+
18
+ // Locate any merchant's bond — derived offline, no RPC call.
19
+ const [bond] = bondPda(merchant, mint);
20
+
21
+ // Read its health: bonded, reserved, and free collateral.
22
+ const info = await connection.getAccountInfo(bond);
23
+ const { amount, reserved, openDisputes } = decodeBond(info.data);
24
+
25
+ // Verify every delivery before paying for the next unit.
26
+ const ok = verifyEd25519(signer, receiptMessageBytes(receipt), signature);
27
+ ```
28
+
29
+ What it covers: `bondPda` / `bindingPda` / `disputePda` / `receiptPda` /
30
+ `treasuryPda` / `claimPda`, instruction builders for all 11 program
31
+ entrypoints, the frozen 185-byte receipt layout, voucher bytes, and
32
+ zero-dependency account decoders. See `examples/bond-watch` for a
33
+ complete third-party monitor built on SDK + web3.js only.
34
+
35
+ ## Buyer patterns (demand-side integration)
36
+
37
+ Upstream channel builders (`openChannelIx` / `topUpIx` / `settleIx`,
38
+ `deriveChannel`) live here so buyers never reimplement
39
+ consensus-critical bytes by copy-paste. The proven demand-side flow:
40
+
41
+ 1. **Gate on collateral.** Derive `bondPda(merchant, mint)` offline,
42
+ `decodeBond` it, refuse when `amount − reserved` is below your
43
+ policy — before opening any channel or locking any escrow.
44
+ 2. **Cover every voucher.** `assertVoucherCoversQuote` enforces
45
+ voucher-delta ≥ quoted cost. This is billing-ack hygiene, NOT
46
+ delivery acceptance: pay for unit N+1 only after verifying unit N's
47
+ receipt AND accepting unit N's output bytes yourself. A valid
48
+ signature never proves correctness — nothing in this SDK will stop
49
+ you from treating it as one, so separate the two checks explicitly.
50
+ 3. **Size disputes from chain, not memory.** `suggestClaimSpend`
51
+ returns `min(binding.maxSpend, funded ATA, floor(bondFree / 3))`.
52
+ A `0n` suggestion means no safe claim exists — do not open.
53
+ 4. **Resolve via any live keeper.** Any keeper watching the program
54
+ resolves matured disputes; verify onchain (dispute account closed,
55
+ claimant +95%). Operate your own for production (see keeper README).
@@ -23,10 +23,25 @@ export interface Dispute {
23
23
  stakeLamports: bigint;
24
24
  claimSpend: bigint;
25
25
  }
26
+ export interface Binding {
27
+ channel: PublicKey;
28
+ merchant: PublicKey;
29
+ bond: PublicKey;
30
+ channelProgram: PublicKey;
31
+ maxSpend: bigint;
32
+ lastNonce: bigint;
33
+ lastCumulativeSpend: bigint;
34
+ halted: boolean;
35
+ }
26
36
  /** MerchantBond layout: disc(8) + merchant(32) + mint(32) + amount(8) +
27
37
  * slaBps(2) + challengeSlots(8) + openDisputes(8) + lastChangeSlot(8) +
28
38
  * reserved(8) + bump(1). */
29
39
  export declare function decodeBond(data: Uint8Array): Bond;
40
+ /** ChannelBinding layout: disc(8) + channel(32) + merchant(32) +
41
+ * bond(32) + channel_program(32) + maxSpend(8) + lastNonce(8) +
42
+ * lastCumulativeSpend(8) + halted(1) + bump(1) = 162 bytes.
43
+ * Offsets verified against a live devnet binding. */
44
+ export declare function decodeBinding(data: Uint8Array): Binding;
30
45
  /** Dispute layout: disc(8) + binding(32) + nonce(8) + reason(1) +
31
46
  * claimant(32) + deadlineSlot(8) + stakeLamports(8) + claimSpend(8) +
32
47
  * bump(1). */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.decodeBond = decodeBond;
4
+ exports.decodeBinding = decodeBinding;
4
5
  exports.decodeDispute = decodeDispute;
5
6
  const web3_js_1 = require("@solana/web3.js");
6
7
  function u64(d, o) {
@@ -34,6 +35,22 @@ function decodeBond(data) {
34
35
  reserved: u64(data, 106),
35
36
  };
36
37
  }
38
+ /** ChannelBinding layout: disc(8) + channel(32) + merchant(32) +
39
+ * bond(32) + channel_program(32) + maxSpend(8) + lastNonce(8) +
40
+ * lastCumulativeSpend(8) + halted(1) + bump(1) = 162 bytes.
41
+ * Offsets verified against a live devnet binding. */
42
+ function decodeBinding(data) {
43
+ return {
44
+ channel: pk(data, 8),
45
+ merchant: pk(data, 40),
46
+ bond: pk(data, 72),
47
+ channelProgram: pk(data, 104),
48
+ maxSpend: u64(data, 136),
49
+ lastNonce: u64(data, 144),
50
+ lastCumulativeSpend: u64(data, 152),
51
+ halted: data[160] !== 0,
52
+ };
53
+ }
37
54
  /** Dispute layout: disc(8) + binding(32) + nonce(8) + reason(1) +
38
55
  * claimant(32) + deadlineSlot(8) + stakeLamports(8) + claimSpend(8) +
39
56
  * bump(1). */
@@ -0,0 +1,61 @@
1
+ import { Connection, PublicKey } from "@solana/web3.js";
2
+ /**
3
+ * Buyer-side incident helpers. These encode the operator judgments a
4
+ * demand buyer otherwise hand-rolls at 2am: how large a claim is safe,
5
+ * and whether a voucher covers the quoted price. Pure chain reads —
6
+ * nothing here opens disputes or moves funds.
7
+ */
8
+ /** A voucher authorizing less than the quoted cost. */
9
+ export declare class VoucherUnderquoted extends Error {
10
+ readonly quoted: bigint;
11
+ readonly authorized: bigint;
12
+ constructor(quoted: bigint, authorized: bigint);
13
+ }
14
+ /** A voucher older than the channel watermark (replay or reorder). */
15
+ export declare class StaleVoucher extends Error {
16
+ readonly watermark: bigint;
17
+ readonly voucherCumulative: bigint;
18
+ constructor(watermark: bigint, voucherCumulative: bigint);
19
+ }
20
+ /**
21
+ * Coverage check: the voucher's marginal authorization must cover the
22
+ * quoted cost. This is billing-ack hygiene, NOT delivery acceptance —
23
+ * a covered voucher says the merchant may be paid, never that the
24
+ * output was correct (verify output bytes separately).
25
+ */
26
+ export declare function assertVoucherCoversQuote(args: {
27
+ lastCumulative: bigint;
28
+ voucherCumulative: bigint;
29
+ quotedCost: bigint;
30
+ }): void;
31
+ export interface ClaimSuggestion {
32
+ /** Nonce to dispute (explicit, or binding.lastNonce + 1). */
33
+ nonce: bigint;
34
+ /** Onchain ceiling: claim_spend must be ≤ this (binding.maxSpend). */
35
+ maxSpend: bigint;
36
+ /** Claimant ATA balance for the bond mint. */
37
+ funded: bigint;
38
+ /** Bond free collateral (amount − reserved). */
39
+ bondFree: bigint;
40
+ /** Largest claim the bond can back: floor(free / 3). */
41
+ bondCap: bigint;
42
+ /**
43
+ * Suggested claim_spend = min(maxSpend, funded, bondCap). 0n means no
44
+ * safe claim exists — do NOT open a dispute (claim_spend must be > 0
45
+ * onchain); top up, wait, or walk away instead.
46
+ */
47
+ claimSpend: bigint;
48
+ }
49
+ /**
50
+ * Maps "failed nonce N" to a safe claim amount. Reads the binding (cap +
51
+ * nonce default), the bond (free collateral — every unit of claim locks
52
+ * 3x: refund + fee + penalty), and the claimant's ATA (the claim stake
53
+ * must actually be funded). Throws when the binding or bond account is
54
+ * missing.
55
+ */
56
+ export declare function suggestClaimSpend(conn: Connection, args: {
57
+ binding: PublicKey;
58
+ claimant: PublicKey;
59
+ mint: PublicKey;
60
+ nonce?: bigint;
61
+ }): Promise<ClaimSuggestion>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StaleVoucher = exports.VoucherUnderquoted = void 0;
4
+ exports.assertVoucherCoversQuote = assertVoucherCoversQuote;
5
+ exports.suggestClaimSpend = suggestClaimSpend;
6
+ const accounts_1 = require("./accounts");
7
+ const pda_1 = require("./pda");
8
+ /**
9
+ * Buyer-side incident helpers. These encode the operator judgments a
10
+ * demand buyer otherwise hand-rolls at 2am: how large a claim is safe,
11
+ * and whether a voucher covers the quoted price. Pure chain reads —
12
+ * nothing here opens disputes or moves funds.
13
+ */
14
+ /** A voucher authorizing less than the quoted cost. */
15
+ class VoucherUnderquoted extends Error {
16
+ quoted;
17
+ authorized;
18
+ constructor(quoted, authorized) {
19
+ super(`voucher authorizes ${authorized} but merchant quoted ${quoted}`);
20
+ this.quoted = quoted;
21
+ this.authorized = authorized;
22
+ this.name = "VoucherUnderquoted";
23
+ }
24
+ }
25
+ exports.VoucherUnderquoted = VoucherUnderquoted;
26
+ /** A voucher older than the channel watermark (replay or reorder). */
27
+ class StaleVoucher extends Error {
28
+ watermark;
29
+ voucherCumulative;
30
+ constructor(watermark, voucherCumulative) {
31
+ super(`voucher cumulative ${voucherCumulative} is behind watermark ${watermark}`);
32
+ this.watermark = watermark;
33
+ this.voucherCumulative = voucherCumulative;
34
+ this.name = "StaleVoucher";
35
+ }
36
+ }
37
+ exports.StaleVoucher = StaleVoucher;
38
+ /**
39
+ * Coverage check: the voucher's marginal authorization must cover the
40
+ * quoted cost. This is billing-ack hygiene, NOT delivery acceptance —
41
+ * a covered voucher says the merchant may be paid, never that the
42
+ * output was correct (verify output bytes separately).
43
+ */
44
+ function assertVoucherCoversQuote(args) {
45
+ if (args.voucherCumulative < args.lastCumulative) {
46
+ throw new StaleVoucher(args.lastCumulative, args.voucherCumulative);
47
+ }
48
+ const authorized = args.voucherCumulative - args.lastCumulative;
49
+ if (authorized < args.quotedCost) {
50
+ throw new VoucherUnderquoted(args.quotedCost, authorized);
51
+ }
52
+ }
53
+ /**
54
+ * Maps "failed nonce N" to a safe claim amount. Reads the binding (cap +
55
+ * nonce default), the bond (free collateral — every unit of claim locks
56
+ * 3x: refund + fee + penalty), and the claimant's ATA (the claim stake
57
+ * must actually be funded). Throws when the binding or bond account is
58
+ * missing.
59
+ */
60
+ async function suggestClaimSpend(conn, args) {
61
+ const bindingInfo = await conn.getAccountInfo(args.binding);
62
+ if (bindingInfo === null)
63
+ throw new Error("binding account missing");
64
+ const binding = (0, accounts_1.decodeBinding)(bindingInfo.data);
65
+ const nonce = args.nonce ?? binding.lastNonce + 1n;
66
+ const bondInfo = await conn.getAccountInfo(binding.bond);
67
+ if (bondInfo === null)
68
+ throw new Error("bond account missing");
69
+ const bond = (0, accounts_1.decodeBond)(bondInfo.data);
70
+ const bondFree = bond.amount - bond.reserved;
71
+ // Outflow per unit of claim is 3x (refund + fee + penalty).
72
+ const bondCap = bondFree >= 0n ? bondFree / 3n : 0n;
73
+ const claimantAta = (0, pda_1.ataFor)(args.claimant, args.mint);
74
+ const ataInfo = await conn.getAccountInfo(claimantAta);
75
+ let funded = 0n;
76
+ if (ataInfo !== null) {
77
+ const bal = await conn.getTokenAccountBalance(claimantAta);
78
+ funded = BigInt(bal.value.amount);
79
+ }
80
+ let claimSpend = binding.maxSpend;
81
+ if (funded < claimSpend)
82
+ claimSpend = funded;
83
+ if (bondCap < claimSpend)
84
+ claimSpend = bondCap;
85
+ return {
86
+ nonce,
87
+ maxSpend: binding.maxSpend,
88
+ funded,
89
+ bondFree,
90
+ bondCap,
91
+ claimSpend,
92
+ };
93
+ }
@@ -0,0 +1,31 @@
1
+ import { PublicKey, TransactionInstruction } from "@solana/web3.js";
2
+ /** Upstream `open`. Layout: disc(1) | salt(8) | deposit(8) | grace(4) | slot(8) | recipients u32. */
3
+ export declare function openChannelIx(args: {
4
+ programId: PublicKey;
5
+ payer: PublicKey;
6
+ payee: PublicKey;
7
+ mint: PublicKey;
8
+ authorizedSigner: PublicKey;
9
+ channel: PublicKey;
10
+ payerAta: PublicKey;
11
+ channelAta: PublicKey;
12
+ eventAuthority: PublicKey;
13
+ salt: bigint;
14
+ deposit: bigint;
15
+ gracePeriod: number;
16
+ openSlot: bigint;
17
+ }): TransactionInstruction;
18
+ export declare function settleIx(programId: PublicKey, channel: PublicKey): TransactionInstruction;
19
+ /** Upstream `top_up`. Layout: disc(3) | amount(8). */
20
+ export declare function topUpIx(args: {
21
+ programId: PublicKey;
22
+ payer: PublicKey;
23
+ channel: PublicKey;
24
+ payerAta: PublicKey;
25
+ channelAta: PublicKey;
26
+ mint: PublicKey;
27
+ amount: bigint;
28
+ }): TransactionInstruction;
29
+ export declare function deriveChannel(programId: PublicKey, payer: PublicKey, payee: PublicKey, mint: PublicKey, authorizedSigner: PublicKey, salt: bigint, openSlot: bigint): {
30
+ channel: PublicKey;
31
+ };
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openChannelIx = openChannelIx;
4
+ exports.settleIx = settleIx;
5
+ exports.topUpIx = topUpIx;
6
+ exports.deriveChannel = deriveChannel;
7
+ const web3_js_1 = require("@solana/web3.js");
8
+ const ids_1 = require("./ids");
9
+ const pda_1 = require("./pda");
10
+ const u64_1 = require("./u64");
11
+ /**
12
+ * Upstream payment-channels instruction builders. These encode the pinned
13
+ * upstream layouts (see docs/upstream-pin.md) so third-party buyers never
14
+ * reimplement consensus-critical bytes by copy-paste. Previously lived in
15
+ * the reference agent; moved here verbatim (byte-identical output).
16
+ */
17
+ function m(pubkey, writable, signer) {
18
+ return { pubkey, isWritable: writable, isSigner: signer };
19
+ }
20
+ function u32(v) {
21
+ const b = Buffer.alloc(4);
22
+ b.writeUInt32LE(v);
23
+ return b;
24
+ }
25
+ /** Upstream `open`. Layout: disc(1) | salt(8) | deposit(8) | grace(4) | slot(8) | recipients u32. */
26
+ function openChannelIx(args) {
27
+ const data = Buffer.concat([
28
+ Buffer.from([1]),
29
+ (0, u64_1.u64le)(args.salt),
30
+ (0, u64_1.u64le)(args.deposit),
31
+ u32(args.gracePeriod),
32
+ (0, u64_1.u64le)(args.openSlot),
33
+ u32(0),
34
+ ]);
35
+ return new web3_js_1.TransactionInstruction({
36
+ programId: args.programId,
37
+ keys: [
38
+ m(args.payer, true, true),
39
+ m(args.payer, true, true),
40
+ m(args.payee, false, false),
41
+ m(args.mint, false, false),
42
+ m(args.authorizedSigner, false, false),
43
+ m(args.channel, true, false),
44
+ m(args.payerAta, true, false),
45
+ m(args.channelAta, true, false),
46
+ m(ids_1.TOKEN_PROGRAM_ID, false, false),
47
+ m(ids_1.SYSTEM_PROGRAM_ID, false, false),
48
+ m(ids_1.RENT_SYSVAR_ID, false, false),
49
+ m(ids_1.ATA_PROGRAM_ID, false, false),
50
+ m(args.eventAuthority, false, false),
51
+ m(args.programId, false, false),
52
+ ],
53
+ data,
54
+ });
55
+ }
56
+ function settleIx(programId, channel) {
57
+ return new web3_js_1.TransactionInstruction({
58
+ programId,
59
+ keys: [m(channel, true, false), m(ids_1.IX_SYSVAR_ID, false, false)],
60
+ data: Buffer.from([2]),
61
+ });
62
+ }
63
+ /** Upstream `top_up`. Layout: disc(3) | amount(8). */
64
+ function topUpIx(args) {
65
+ return new web3_js_1.TransactionInstruction({
66
+ programId: args.programId,
67
+ keys: [
68
+ m(args.payer, true, true),
69
+ m(args.channel, true, false),
70
+ m(args.payerAta, true, false),
71
+ m(args.channelAta, true, false),
72
+ m(args.mint, false, false),
73
+ m(ids_1.TOKEN_PROGRAM_ID, false, false),
74
+ ],
75
+ data: Buffer.concat([Buffer.from([3]), (0, u64_1.u64le)(args.amount)]),
76
+ });
77
+ }
78
+ function deriveChannel(programId, payer, payee, mint, authorizedSigner, salt, openSlot) {
79
+ const [channel] = (0, pda_1.channelPda)(programId, payer, payee, mint, authorizedSigner, salt, openSlot);
80
+ return { channel };
81
+ }
@@ -5,6 +5,7 @@ exports.buildEd25519Ix = buildEd25519Ix;
5
5
  exports.channelVoucherBytes = channelVoucherBytes;
6
6
  const web3_js_1 = require("@solana/web3.js");
7
7
  const ids_1 = require("./ids");
8
+ const u64_1 = require("./u64");
8
9
  /**
9
10
  * Canonical single-signature Ed25519 precompile ix. Offsets point into this
10
11
  * ix's own data (indices u16::MAX). Layout: header(16) | pubkey(32) |
@@ -40,8 +41,8 @@ function channelVoucherBytes(channel, cumulative, expiresAt) {
40
41
  out.writeUInt8(0x56, 0);
41
42
  out.writeUInt8(0x01, 1);
42
43
  channel.toBuffer().copy(out, 2);
43
- out.writeBigUInt64LE(cumulative, 34);
44
- out.writeBigInt64LE(expiresAt, 42);
44
+ (0, u64_1.writeU64LE)(out, cumulative, 34);
45
+ (0, u64_1.writeI64LE)(out, expiresAt, 42);
45
46
  return out;
46
47
  }
47
48
  exports.VOUCHER_LEN = 50;
@@ -12,6 +12,7 @@ exports.haltBindingIx = haltBindingIx;
12
12
  exports.resolveTimeoutIx = resolveTimeoutIx;
13
13
  const web3_js_1 = require("@solana/web3.js");
14
14
  const ids_1 = require("./ids");
15
+ const u64_1 = require("./u64");
15
16
  const D = {
16
17
  registerMerchant: [238, 245, 77, 132, 161, 88, 216, 248],
17
18
  postBond: [168, 151, 202, 119, 163, 58, 147, 247],
@@ -36,9 +37,7 @@ function u16(v) {
36
37
  return b;
37
38
  }
38
39
  function u64(v) {
39
- const b = Buffer.alloc(8);
40
- b.writeBigUInt64LE(v);
41
- return b;
40
+ return (0, u64_1.u64le)(v);
42
41
  }
43
42
  function pk(k) {
44
43
  return k.toBuffer();
@@ -2,6 +2,10 @@ export * from "./ids";
2
2
  export * from "./pda";
3
3
  export * from "./accounts";
4
4
  export * from "./receipt";
5
+ export * from "./tx";
6
+ export * from "./u64";
7
+ export * from "./channels";
8
+ export * from "./buyer";
5
9
  export * from "./ed25519";
6
10
  export * from "./verify";
7
11
  export * from "./harbor-ix";
package/dist/src/index.js CHANGED
@@ -18,6 +18,10 @@ __exportStar(require("./ids"), exports);
18
18
  __exportStar(require("./pda"), exports);
19
19
  __exportStar(require("./accounts"), exports);
20
20
  __exportStar(require("./receipt"), exports);
21
+ __exportStar(require("./tx"), exports);
22
+ __exportStar(require("./u64"), exports);
23
+ __exportStar(require("./channels"), exports);
24
+ __exportStar(require("./buyer"), exports);
21
25
  __exportStar(require("./ed25519"), exports);
22
26
  __exportStar(require("./verify"), exports);
23
27
  __exportStar(require("./harbor-ix"), exports);
package/dist/src/pda.d.ts CHANGED
@@ -7,4 +7,6 @@ export declare function treasuryPda(mint: PublicKey): [PublicKey, number];
7
7
  /** Tombstone marking a (binding, nonce) as claimed exactly once. */
8
8
  export declare function claimPda(binding: PublicKey, nonce: bigint): [PublicKey, number];
9
9
  export declare function channelPda(channelProgram: PublicKey, payer: PublicKey, payee: PublicKey, mint: PublicKey, authorizedSigner: PublicKey, salt: bigint, openSlot: bigint): [PublicKey, number];
10
+ /** Canonical associated token address for (owner, mint). */
11
+ export declare function ataFor(owner: PublicKey, mint: PublicKey): PublicKey;
10
12
  export declare function channelAta(channel: PublicKey, tokenProgram: PublicKey, mint: PublicKey, ataProgram: PublicKey): [PublicKey, number];
package/dist/src/pda.js CHANGED
@@ -7,9 +7,12 @@ exports.disputePda = disputePda;
7
7
  exports.treasuryPda = treasuryPda;
8
8
  exports.claimPda = claimPda;
9
9
  exports.channelPda = channelPda;
10
+ exports.ataFor = ataFor;
10
11
  exports.channelAta = channelAta;
11
12
  const web3_js_1 = require("@solana/web3.js");
12
13
  const ids_1 = require("./ids");
14
+ const ids_2 = require("./ids");
15
+ const u64_1 = require("./u64");
13
16
  function bondPda(merchant, mint) {
14
17
  return web3_js_1.PublicKey.findProgramAddressSync([Buffer.from("bond"), merchant.toBuffer(), mint.toBuffer()], ids_1.HARBOR_PROGRAM_ID);
15
18
  }
@@ -17,13 +20,11 @@ function bindingPda(channel) {
17
20
  return web3_js_1.PublicKey.findProgramAddressSync([Buffer.from("binding"), channel.toBuffer()], ids_1.HARBOR_PROGRAM_ID);
18
21
  }
19
22
  function receiptPda(binding, nonce) {
20
- const nonceBuf = Buffer.alloc(8);
21
- nonceBuf.writeBigUInt64LE(nonce);
23
+ const nonceBuf = (0, u64_1.u64le)(nonce);
22
24
  return web3_js_1.PublicKey.findProgramAddressSync([Buffer.from("receipt"), binding.toBuffer(), nonceBuf], ids_1.HARBOR_PROGRAM_ID);
23
25
  }
24
26
  function disputePda(binding, nonce) {
25
- const nonceBuf = Buffer.alloc(8);
26
- nonceBuf.writeBigUInt64LE(nonce);
27
+ const nonceBuf = (0, u64_1.u64le)(nonce);
27
28
  return web3_js_1.PublicKey.findProgramAddressSync([Buffer.from("dispute"), binding.toBuffer(), nonceBuf], ids_1.HARBOR_PROGRAM_ID);
28
29
  }
29
30
  function treasuryPda(mint) {
@@ -31,15 +32,12 @@ function treasuryPda(mint) {
31
32
  }
32
33
  /** Tombstone marking a (binding, nonce) as claimed exactly once. */
33
34
  function claimPda(binding, nonce) {
34
- const nonceBuf = Buffer.alloc(8);
35
- nonceBuf.writeBigUInt64LE(nonce);
35
+ const nonceBuf = (0, u64_1.u64le)(nonce);
36
36
  return web3_js_1.PublicKey.findProgramAddressSync([Buffer.from("claim"), binding.toBuffer(), nonceBuf], ids_1.HARBOR_PROGRAM_ID);
37
37
  }
38
38
  function channelPda(channelProgram, payer, payee, mint, authorizedSigner, salt, openSlot) {
39
- const saltBuf = Buffer.alloc(8);
40
- saltBuf.writeBigUInt64LE(salt);
41
- const slotBuf = Buffer.alloc(8);
42
- slotBuf.writeBigUInt64LE(openSlot);
39
+ const saltBuf = (0, u64_1.u64le)(salt);
40
+ const slotBuf = (0, u64_1.u64le)(openSlot);
43
41
  return web3_js_1.PublicKey.findProgramAddressSync([
44
42
  Buffer.from("channel"),
45
43
  payer.toBuffer(),
@@ -50,6 +48,10 @@ function channelPda(channelProgram, payer, payee, mint, authorizedSigner, salt,
50
48
  slotBuf,
51
49
  ], channelProgram);
52
50
  }
51
+ /** Canonical associated token address for (owner, mint). */
52
+ function ataFor(owner, mint) {
53
+ return web3_js_1.PublicKey.findProgramAddressSync([owner.toBuffer(), ids_2.TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], ids_2.ATA_PROGRAM_ID)[0];
54
+ }
53
55
  function channelAta(channel, tokenProgram, mint, ataProgram) {
54
56
  return web3_js_1.PublicKey.findProgramAddressSync([channel.toBuffer(), tokenProgram.toBuffer(), mint.toBuffer()], ataProgram);
55
57
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RECEIPT_MESSAGE_LEN = void 0;
4
4
  exports.receiptMessageBytes = receiptMessageBytes;
5
+ const u64_1 = require("./u64");
5
6
  function assertLen(b, n, name) {
6
7
  if (b.length !== n)
7
8
  throw new Error(`${name} must be ${n} bytes`);
@@ -16,7 +17,7 @@ function receiptMessageBytes(f) {
16
17
  o += 32;
17
18
  f.binding.toBuffer().copy(out, o);
18
19
  o += 32;
19
- out.writeBigUInt64LE(f.cumulativeSpend, o);
20
+ (0, u64_1.writeU64LE)(out, f.cumulativeSpend, o);
20
21
  o += 8;
21
22
  Buffer.from(f.meterHash).copy(out, o);
22
23
  o += 32;
@@ -24,9 +25,9 @@ function receiptMessageBytes(f) {
24
25
  o += 32;
25
26
  out.writeUInt8(f.status, o);
26
27
  o += 1;
27
- out.writeBigUInt64LE(f.nonce, o);
28
+ (0, u64_1.writeU64LE)(out, f.nonce, o);
28
29
  o += 8;
29
- out.writeBigUInt64LE(f.expirySlot, o);
30
+ (0, u64_1.writeU64LE)(out, f.expirySlot, o);
30
31
  o += 8;
31
32
  f.signer.toBuffer().copy(out, o);
32
33
  o += 32;
@@ -0,0 +1,11 @@
1
+ import { Connection, Signer, Transaction, TransactionSignature } from "@solana/web3.js";
2
+ /** True only for errors proving the tx can never confirm (safe to rebuild). */
3
+ export declare function isExpiredError(e: unknown): boolean;
4
+ /**
5
+ * Send with fresh-blockhash retries. Each attempt rebuilds the tx so a
6
+ * new blockhash is fetched; only expiry-proven failures retry (timeouts
7
+ * and unknown states propagate — resending those could double-execute).
8
+ * All Harbor ixs are init-or-check guarded, so a rebuilt resend after a
9
+ * proven expiry fails safe even in the worst case.
10
+ */
11
+ export declare function sendWithRetry(conn: Connection, buildTx: () => Transaction, signers: Signer[], maxTries?: number): Promise<TransactionSignature>;
package/dist/src/tx.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isExpiredError = isExpiredError;
4
+ exports.sendWithRetry = sendWithRetry;
5
+ const web3_js_1 = require("@solana/web3.js");
6
+ const EXPIRY = /block height exceeded|blockhash not found/i;
7
+ /** True only for errors proving the tx can never confirm (safe to rebuild). */
8
+ function isExpiredError(e) {
9
+ const msg = e instanceof Error ? e.message : String(e);
10
+ return EXPIRY.test(msg);
11
+ }
12
+ /**
13
+ * Send with fresh-blockhash retries. Each attempt rebuilds the tx so a
14
+ * new blockhash is fetched; only expiry-proven failures retry (timeouts
15
+ * and unknown states propagate — resending those could double-execute).
16
+ * All Harbor ixs are init-or-check guarded, so a rebuilt resend after a
17
+ * proven expiry fails safe even in the worst case.
18
+ */
19
+ async function sendWithRetry(conn, buildTx, signers, maxTries = 3) {
20
+ let lastErr = new Error("sendWithRetry: no attempts made");
21
+ for (let attempt = 1; attempt <= maxTries; attempt++) {
22
+ try {
23
+ return await (0, web3_js_1.sendAndConfirmTransaction)(conn, buildTx(), signers);
24
+ }
25
+ catch (e) {
26
+ lastErr = e;
27
+ if (attempt >= maxTries || !isExpiredError(e))
28
+ throw e;
29
+ }
30
+ }
31
+ throw lastErr;
32
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Portable little-endian 64-bit encoders.
3
+ *
4
+ * Browser Buffer polyfills predate the BigInt read/write methods, so the
5
+ * SDK must never call writeBigUInt64LE / writeBigInt64LE directly — any
6
+ * bundler-shipped dapp (including our own web UI) would crash at runtime.
7
+ * Index assignment works on every Buffer/Uint8Array implementation.
8
+ */
9
+ export declare function writeU64LE(out: Uint8Array, v: bigint | number, offset: number): void;
10
+ export declare function writeI64LE(out: Uint8Array, v: bigint | number, offset: number): void;
11
+ export declare function u64le(v: bigint | number): Buffer;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeU64LE = writeU64LE;
4
+ exports.writeI64LE = writeI64LE;
5
+ exports.u64le = u64le;
6
+ /**
7
+ * Portable little-endian 64-bit encoders.
8
+ *
9
+ * Browser Buffer polyfills predate the BigInt read/write methods, so the
10
+ * SDK must never call writeBigUInt64LE / writeBigInt64LE directly — any
11
+ * bundler-shipped dapp (including our own web UI) would crash at runtime.
12
+ * Index assignment works on every Buffer/Uint8Array implementation.
13
+ */
14
+ function writeU64LE(out, v, offset) {
15
+ let x = typeof v === "bigint" ? v : BigInt(v);
16
+ if (x < 0n || x >= 1n << 64n)
17
+ throw new RangeError("u64 out of range");
18
+ for (let i = 0; i < 8; i++) {
19
+ out[offset + i] = Number(x & 0xffn);
20
+ x >>= 8n;
21
+ }
22
+ }
23
+ function writeI64LE(out, v, offset) {
24
+ let x = typeof v === "bigint" ? v : BigInt(v);
25
+ if (x < -(1n << 63n) || x >= 1n << 63n)
26
+ throw new RangeError("i64 out of range");
27
+ if (x < 0n)
28
+ x += 1n << 64n;
29
+ writeU64LE(out, x, offset);
30
+ }
31
+ function u64le(v) {
32
+ const b = Buffer.alloc(8);
33
+ writeU64LE(b, v, 0);
34
+ return b;
35
+ }
@@ -141,3 +141,294 @@ const P = (n) => new web3_js_1.PublicKey(Buffer.alloc(32, n));
141
141
  strict_1.default.equal(d.claimSpend, 3670n);
142
142
  });
143
143
  });
144
+ (0, node_test_1.describe)("sendWithRetry", () => {
145
+ function mockConn(script) {
146
+ let builds = 0;
147
+ const conn = {
148
+ getLatestBlockhash: async () => ({
149
+ blockhash: "11111111111111111111111111111111",
150
+ lastValidBlockHeight: 1,
151
+ }),
152
+ sendTransaction: async () => {
153
+ const step = script.shift();
154
+ if (step === "expiry") {
155
+ throw new Error("Signature X has expired: block height exceeded.");
156
+ }
157
+ if (step === "fatal")
158
+ throw new Error("simulated failure");
159
+ return "sig-ok";
160
+ },
161
+ confirmTransaction: async () => ({ value: { err: null } }),
162
+ };
163
+ return {
164
+ conn,
165
+ builds: () => builds,
166
+ build: () => {
167
+ builds += 1;
168
+ return new web3_js_1.Transaction();
169
+ },
170
+ };
171
+ }
172
+ (0, node_test_1.it)("rebuilds and resends once after a single expiry", async () => {
173
+ const { conn, builds, build } = mockConn(["expiry", "ok"]);
174
+ const sig = await (0, index_1.sendWithRetry)(conn, build, [], 3);
175
+ strict_1.default.equal(sig, "sig-ok");
176
+ strict_1.default.equal(builds(), 2);
177
+ });
178
+ (0, node_test_1.it)("gives up after maxTries on repeated expiry", async () => {
179
+ const { conn, builds, build } = mockConn(["expiry", "expiry", "expiry"]);
180
+ await strict_1.default.rejects((0, index_1.sendWithRetry)(conn, build, [], 3), /block height exceeded/);
181
+ strict_1.default.equal(builds(), 3);
182
+ });
183
+ (0, node_test_1.it)("propagates non-expiry failures immediately without rebuild", async () => {
184
+ const { conn, builds, build } = mockConn(["fatal", "ok"]);
185
+ await strict_1.default.rejects((0, index_1.sendWithRetry)(conn, build, [], 3), /simulated failure/);
186
+ strict_1.default.equal(builds(), 1);
187
+ });
188
+ });
189
+ (0, node_test_1.describe)("portable u64 encoders", () => {
190
+ (0, node_test_1.it)("matches native writeBigUInt64LE byte-for-byte", () => {
191
+ for (const v of [0n, 1n, 255n, 256n, 2n ** 32n, 2n ** 64n - 1n]) {
192
+ const expected = Buffer.alloc(8);
193
+ expected.writeBigUInt64LE(v);
194
+ strict_1.default.deepEqual((0, index_1.u64le)(v), expected);
195
+ const out = Buffer.alloc(8);
196
+ (0, index_1.writeU64LE)(out, v, 0);
197
+ strict_1.default.deepEqual(out, expected);
198
+ }
199
+ });
200
+ (0, node_test_1.it)("encodes signed i64 incl. negatives like writeBigInt64LE", () => {
201
+ for (const v of [0n, 1n, -1n, -(2n ** 63n), 2n ** 63n - 1n]) {
202
+ const expected = Buffer.alloc(8);
203
+ expected.writeBigInt64LE(v);
204
+ const out = Buffer.alloc(8);
205
+ (0, index_1.writeI64LE)(out, v, 0);
206
+ strict_1.default.deepEqual(out, expected);
207
+ }
208
+ });
209
+ (0, node_test_1.it)("rejects out-of-range values instead of wrapping", () => {
210
+ strict_1.default.throws(() => (0, index_1.u64le)(-1n), RangeError);
211
+ strict_1.default.throws(() => (0, index_1.u64le)(2n ** 64n), RangeError);
212
+ strict_1.default.throws(() => (0, index_1.writeI64LE)(Buffer.alloc(8), 2n ** 63n, 0), RangeError);
213
+ });
214
+ });
215
+ (0, node_test_1.describe)("upstream channel builders (moved from agent, byte-identical)", () => {
216
+ const PROGRAM = P(1);
217
+ const args = {
218
+ programId: PROGRAM,
219
+ payer: P(2),
220
+ payee: P(3),
221
+ mint: P(4),
222
+ authorizedSigner: P(5),
223
+ channel: P(6),
224
+ payerAta: P(7),
225
+ channelAta: P(8),
226
+ eventAuthority: P(9),
227
+ salt: 42n,
228
+ deposit: 5000000n,
229
+ gracePeriod: 7200,
230
+ openSlot: 99n,
231
+ };
232
+ (0, node_test_1.it)("open encodes disc | salt | deposit | grace | slot | recipients", () => {
233
+ const ix = (0, index_1.openChannelIx)(args);
234
+ const d = Buffer.from(ix.data);
235
+ strict_1.default.equal(d.readUInt8(0), 1);
236
+ strict_1.default.equal(d.readBigUInt64LE(1), 42n);
237
+ strict_1.default.equal(d.readBigUInt64LE(9), 5000000n);
238
+ strict_1.default.equal(d.readUInt32LE(17), 7200);
239
+ strict_1.default.equal(d.readBigUInt64LE(21), 99n);
240
+ strict_1.default.equal(d.readUInt32LE(29), 0);
241
+ strict_1.default.equal(d.length, 33);
242
+ strict_1.default.equal(ix.keys.length, 14);
243
+ strict_1.default.ok(ix.programId.equals(PROGRAM));
244
+ });
245
+ (0, node_test_1.it)("settle is a bare discriminator with channel + sysvar", () => {
246
+ const ix = (0, index_1.settleIx)(PROGRAM, P(6));
247
+ strict_1.default.deepEqual(Array.from(ix.data), [2]);
248
+ strict_1.default.equal(ix.keys.length, 2);
249
+ });
250
+ (0, node_test_1.it)("top_up encodes disc | amount with 6 accounts", () => {
251
+ const ix = (0, index_1.topUpIx)({
252
+ programId: PROGRAM,
253
+ payer: P(2),
254
+ channel: P(6),
255
+ payerAta: P(7),
256
+ channelAta: P(8),
257
+ mint: P(4),
258
+ amount: 9n,
259
+ });
260
+ const d = Buffer.from(ix.data);
261
+ strict_1.default.equal(d.readUInt8(0), 3);
262
+ strict_1.default.equal(d.readBigUInt64LE(1), 9n);
263
+ strict_1.default.equal(d.length, 9);
264
+ strict_1.default.equal(ix.keys.length, 6);
265
+ });
266
+ (0, node_test_1.it)("deriveChannel matches channelPda", () => {
267
+ const { channel } = (0, index_1.deriveChannel)(P(1), P(2), P(3), P(4), P(5), 42n, 99n);
268
+ strict_1.default.equal(channel.toBase58().length, 44);
269
+ });
270
+ });
271
+ (0, node_test_1.describe)("decodeBinding", () => {
272
+ function bindingBytes() {
273
+ const b = Buffer.alloc(162);
274
+ P(6).toBuffer().copy(b, 8);
275
+ P(11).toBuffer().copy(b, 40);
276
+ P(12).toBuffer().copy(b, 72);
277
+ P(13).toBuffer().copy(b, 104);
278
+ b.writeBigUInt64LE(2000n, 136);
279
+ b.writeBigUInt64LE(4n, 144);
280
+ b.writeBigUInt64LE(15000n, 152);
281
+ b.writeUInt8(0, 160);
282
+ return b;
283
+ }
284
+ (0, node_test_1.it)("decodes every field at its documented offset", () => {
285
+ const d = (0, index_1.decodeBinding)(bindingBytes());
286
+ strict_1.default.ok(d.channel.equals(P(6)));
287
+ strict_1.default.ok(d.merchant.equals(P(11)));
288
+ strict_1.default.ok(d.bond.equals(P(12)));
289
+ strict_1.default.ok(d.channelProgram.equals(P(13)));
290
+ strict_1.default.equal(d.maxSpend, 2000n);
291
+ strict_1.default.equal(d.lastNonce, 4n);
292
+ strict_1.default.equal(d.lastCumulativeSpend, 15000n);
293
+ strict_1.default.equal(d.halted, false);
294
+ });
295
+ });
296
+ (0, node_test_1.describe)("assertVoucherCoversQuote", () => {
297
+ (0, node_test_1.it)("passes when the marginal authorization covers the quote", () => {
298
+ (0, index_1.assertVoucherCoversQuote)({
299
+ lastCumulative: 10000n,
300
+ voucherCumulative: 12050n,
301
+ quotedCost: 2000n,
302
+ });
303
+ });
304
+ (0, node_test_1.it)("throws VoucherUnderquoted on short authorization", () => {
305
+ strict_1.default.throws(() => (0, index_1.assertVoucherCoversQuote)({
306
+ lastCumulative: 10000n,
307
+ voucherCumulative: 11000n,
308
+ quotedCost: 2000n,
309
+ }), index_1.VoucherUnderquoted);
310
+ });
311
+ (0, node_test_1.it)("throws StaleVoucher when behind the watermark", () => {
312
+ strict_1.default.throws(() => (0, index_1.assertVoucherCoversQuote)({
313
+ lastCumulative: 10000n,
314
+ voucherCumulative: 9000n,
315
+ quotedCost: 1n,
316
+ }), index_1.StaleVoucher);
317
+ });
318
+ });
319
+ (0, node_test_1.describe)("suggestClaimSpend", () => {
320
+ const BINDING = P(20);
321
+ const CLAIMANT = P(21);
322
+ const MINT = P(22);
323
+ const BOND = P(23);
324
+ function bondBytes(amount, reserved) {
325
+ const b = Buffer.alloc(115);
326
+ P(11).toBuffer().copy(b, 8);
327
+ MINT.toBuffer().copy(b, 40);
328
+ b.writeBigUInt64LE(amount, 72);
329
+ b.writeBigUInt64LE(reserved, 106);
330
+ return b;
331
+ }
332
+ function bindingBytes(maxSpend, lastNonce) {
333
+ const b = Buffer.alloc(162);
334
+ P(6).toBuffer().copy(b, 8);
335
+ P(11).toBuffer().copy(b, 40);
336
+ BOND.toBuffer().copy(b, 72);
337
+ P(13).toBuffer().copy(b, 104);
338
+ b.writeBigUInt64LE(maxSpend, 136);
339
+ b.writeBigUInt64LE(lastNonce, 144);
340
+ return b;
341
+ }
342
+ function mockConn(opts) {
343
+ const ata = (0, index_1.ataFor)(CLAIMANT, MINT);
344
+ return {
345
+ getAccountInfo: async (k) => {
346
+ const key = k.toBase58();
347
+ if (key === BINDING.toBase58())
348
+ return opts.binding === null ? null : { data: opts.binding };
349
+ if (key === BOND.toBase58())
350
+ return { data: bondBytes(opts.bondAmount, opts.bondReserved) };
351
+ if (key === ata.toBase58())
352
+ return opts.ataExists ? { data: Buffer.alloc(165) } : null;
353
+ return null;
354
+ },
355
+ getTokenAccountBalance: async () => ({
356
+ value: { amount: opts.funded.toString() },
357
+ }),
358
+ };
359
+ }
360
+ const base = {
361
+ binding: BINDING,
362
+ claimant: CLAIMANT,
363
+ mint: MINT,
364
+ };
365
+ (0, node_test_1.it)("caps at binding maxSpend and defaults nonce to lastNonce + 1", async () => {
366
+ const conn = mockConn({
367
+ binding: bindingBytes(2000n, 4n),
368
+ bondAmount: 500000n,
369
+ bondReserved: 0n,
370
+ funded: 100000n,
371
+ ataExists: true,
372
+ });
373
+ const s = await (0, index_1.suggestClaimSpend)(conn, base);
374
+ strict_1.default.equal(s.nonce, 5n);
375
+ strict_1.default.equal(s.maxSpend, 2000n);
376
+ strict_1.default.equal(s.funded, 100000n);
377
+ strict_1.default.equal(s.claimSpend, 2000n);
378
+ });
379
+ (0, node_test_1.it)("caps at funded balance when the claimant is thin", async () => {
380
+ const conn = mockConn({
381
+ binding: bindingBytes(2000n, 4n),
382
+ bondAmount: 500000n,
383
+ bondReserved: 0n,
384
+ funded: 500n,
385
+ ataExists: true,
386
+ });
387
+ const s = await (0, index_1.suggestClaimSpend)(conn, base);
388
+ strict_1.default.equal(s.claimSpend, 500n);
389
+ });
390
+ (0, node_test_1.it)("caps at bond capacity floor(free / 3)", async () => {
391
+ const conn = mockConn({
392
+ binding: bindingBytes(2000n, 4n),
393
+ bondAmount: 3000n,
394
+ bondReserved: 0n,
395
+ funded: 100000n,
396
+ ataExists: true,
397
+ });
398
+ const s = await (0, index_1.suggestClaimSpend)(conn, base);
399
+ strict_1.default.equal(s.bondFree, 3000n);
400
+ strict_1.default.equal(s.bondCap, 1000n);
401
+ strict_1.default.equal(s.claimSpend, 1000n);
402
+ });
403
+ (0, node_test_1.it)("suggests 0 when the ATA is missing (do not open)", async () => {
404
+ const conn = mockConn({
405
+ binding: bindingBytes(2000n, 4n),
406
+ bondAmount: 500000n,
407
+ bondReserved: 0n,
408
+ funded: 0n,
409
+ ataExists: false,
410
+ });
411
+ const s = await (0, index_1.suggestClaimSpend)(conn, base);
412
+ strict_1.default.equal(s.funded, 0n);
413
+ strict_1.default.equal(s.claimSpend, 0n);
414
+ });
415
+ (0, node_test_1.it)("throws on missing binding and passes explicit nonce", async () => {
416
+ const conn = mockConn({
417
+ binding: null,
418
+ bondAmount: 0n,
419
+ bondReserved: 0n,
420
+ funded: 0n,
421
+ ataExists: false,
422
+ });
423
+ await strict_1.default.rejects((0, index_1.suggestClaimSpend)(conn, base), /binding/);
424
+ const conn2 = mockConn({
425
+ binding: bindingBytes(2000n, 4n),
426
+ bondAmount: 500000n,
427
+ bondReserved: 0n,
428
+ funded: 100000n,
429
+ ataExists: true,
430
+ });
431
+ const s = await (0, index_1.suggestClaimSpend)(conn2, { ...base, nonce: 9n });
432
+ strict_1.default.equal(s.nonce, 9n);
433
+ });
434
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@infantmen-labs/harbor-sdk",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for Harbor bonded refunds on Solana: PDA helpers, instruction builders, receipt codec, ed25519 verify, account decoders.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -17,6 +17,7 @@
17
17
  "types": "dist/src/index.d.ts",
18
18
  "scripts": {
19
19
  "build": "tsc -p .",
20
+ "clean": "rm -rf dist",
20
21
  "test": "node --test dist/test/vectors.test.js"
21
22
  },
22
23
  "dependencies": {