Repository: algorand/js-algorand-sdk
Pull request #918 (2025-01-14 02:51:34)

Incentives: Support heartbeat transaction (v2.9)
## Summary

Add heartbeat txn support. Note, this change includes models regenerated in https://github.com/algorand/js-algorand-sdk/pull/917 (i.e. subsequent PR)

## Testing

1. Added golden txn test
2. Added cross sdk tests


Patch:
diff --git a/Makefile b/Makefile
index 057f893..b2acb14 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ INTEGRATIONS_TAGS := "$(subst :, or ,$(shell awk '{print $2}' tests/cucumber/int
 
 unit:
 	node_modules/.bin/cucumber-js --tags $(UNIT_TAGS) tests/cucumber/features --require-module ts-node/register --require tests/cucumber/steps/index.js
-	
+
 integration:
 	node_modules/.bin/cucumber-js --tags $(INTEGRATIONS_TAGS) tests/cucumber/features --require-module ts-node/register --require tests/cucumber/steps/index.js
 
diff --git a/src/client/v2/algod/models/types.ts b/src/client/v2/algod/models/types.ts
index f77a3fa..3c7e399 100644
--- a/src/client/v2/algod/models/types.ts
+++ b/src/client/v2/algod/models/types.ts
@@ -2098,6 +2098,38 @@ export class BlockHashResponse extends BaseModel {
   }
 }
 
+/**
+ * Block header.
+ */
+export class BlockHeaderResponse extends BaseModel {
+  /**
+   * Block header data.
+   */
+  public blockheader?: BlockHeader;
+
+  /**
+   * Creates a new `BlockHeaderResponse` object.
+   * @param blockheader - Block header data.
+   */
+  constructor({ blockheader }: { blockheader?: BlockHeader }) {
+    super();
+    this.blockheader = blockheader;
+
+    this.attribute_map = {
+      blockheader: 'blockHeader',
+    };
+  }
+
+  // eslint-disable-next-line camelcase
+  static from_obj_for_encoding(data: Record<string, any>): BlockHeaderResponse {
+    /* eslint-disable dot-notation */
+    return new BlockHeaderResponse({
+      blockheader: data['blockHeader'],
+    });
+    /* eslint-enable dot-notation */
+  }
+}
+
 /**
  * All logs emitted in the given round. Each app call, whether top-level or inner,
  * that contains logs results in a separate AppCallLogs object. Therefore there may
diff --git a/src/client/v2/indexer/models/types.ts b/src/client/v2/indexer/models/types.ts
index 7d51e99..31a5103 100644
--- a/src/client/v2/indexer/models/types.ts
+++ b/src/client/v2/indexer/models/types.ts
@@ -2467,6 +2467,68 @@ export class Block extends BaseModel {
   }
 }
 
+/**
+ *
+ */
+export class BlockHeadersResponse extends BaseModel {
+  public blocks?: Block[];
+
+  /**
+   * Round at which the results were computed.
+   */
+  public currentRound?: number | bigint;
+
+  /**
+   * Used for pagination, when making another request provide this token with the
+   * next parameter.
+   */
+  public nextToken?: string;
+
+  /**
+   * Creates a new `BlockHeadersResponse` object.
+   * @param blocks -
+   * @param currentRound - Round at which the results were computed.
+   * @param nextToken - Used for pagination, when making another request provide this token with the
+   * next parameter.
+   */
+  constructor({
+    blocks,
+    currentRound,
+    nextToken,
+  }: {
+    blocks?: Block[];
+    currentRound?: number | bigint;
+    nextToken?: string;
+  }) {
+    super();
+    this.blocks = blocks;
+    this.currentRound = currentRound;
+    this.nextToken = nextToken;
+
+    this.attribute_map = {
+      blocks: 'blocks',
+      currentRound: 'current-round',
+      nextToken: 'next-token',
+    };
+  }
+
+  // eslint-disable-next-line camelcase
+  static from_obj_for_encoding(
+    data: Record<string, any>
+  ): BlockHeadersResponse {
+    /* eslint-disable dot-notation */
+    return new BlockHeadersResponse({
+      blocks:
+        typeof data['blocks'] !== 'undefined'
+          ? data['blocks'].map(Block.from_obj_for_encoding)
+          : undefined,
+      currentRound: data['current-round'],
+      nextToken: data['next-token'],
+    });
+    /* eslint-enable dot-notation */
+  }
+}
+
 /**
  * Fields relating to rewards,
  */
@@ -3080,6 +3142,106 @@ export class HashFactory extends BaseModel {
   }
 }
 
+/**
+ * (hbprf) HbProof is a signature using HeartbeatAddress's partkey, thereby showing
+ * it is online.
+ */
+export class HbProofFields extends BaseModel {
+  /**
+   * (p) Public key of the heartbeat message.
+   */
+  public hbPk?: Uint8Array;
+
+  /**
+   * (p1s) Signature of OneTimeSignatureSubkeyOffsetID(PK, Batch, Offset) under the
+   * key PK2.
+   */
+  public hbPk1sig?: Uint8Array;
+
+  /**
+   * (p2) Key for new-style two-level ephemeral signature.
+   */
+  public hbPk2?: Uint8Array;
+
+  /**
+   * (p2s) Signature of OneTimeSignatureSubkeyBatchID(PK2, Batch) under the master
+   * key (OneTimeSignatureVerifier).
+   */
+  public hbPk2sig?: Uint8Array;
+
+  /**
+   * (s) Signature of the heartbeat message.
+   */
+  public hbSig?: Uint8Array;
+
+  /**
+   * Creates a new `HbProofFields` object.
+   * @param hbPk - (p) Public key of the heartbeat message.
+   * @param hbPk1sig - (p1s) Signature of OneTimeSignatureSubkeyOffsetID(PK, Batch, Offset) under the
+   * key PK2.
+   * @param hbPk2 - (p2) Key for new-style two-level ephemeral signature.
+   * @param hbPk2sig - (p2s) Signature of OneTimeSignatureSubkeyBatchID(PK2, Batch) under the master
+   * key (OneTimeSignatureVerifier).
+   * @param hbSig - (s) Signature of the heartbeat message.
+   */
+  constructor({
+    hbPk,
+    hbPk1sig,
+    hbPk2,
+    hbPk2sig,
+    hbSig,
+  }: {
+    hbPk?: string | Uint8Array;
+    hbPk1sig?: string | Uint8Array;
+    hbPk2?: string | Uint8Array;
+    hbPk2sig?: string | Uint8Array;
+    hbSig?: string | Uint8Array;
+  }) {
+    super();
+    this.hbPk =
+      typeof hbPk === 'string'
+        ? new Uint8Array(Buffer.from(hbPk, 'base64'))
+        : hbPk;
+    this.hbPk1sig =
+      typeof hbPk1sig === 'string'
+        ? new Uint8Array(Buffer.from(hbPk1sig, 'base64'))
+        : hbPk1sig;
+    this.hbPk2 =
+      typeof hbPk2 === 'string'
+        ? new Uint8Array(Buffer.from(hbPk2, 'base64'))
+        : hbPk2;
+    this.hbPk2sig =
+      typeof hbPk2sig === 'string'
+        ? new Uint8Array(Buffer.from(hbPk2sig, 'base64'))
+        : hbPk2sig;
+    this.hbSig =
+      typeof hbSig === 'string'
+        ? new Uint8Array(Buffer.from(hbSig, 'base64'))
+        : hbSig;
+
+    this.attribute_map = {
+      hbPk: 'hb-pk',
+      hbPk1sig: 'hb-pk1sig',
+      hbPk2: 'hb-pk2',
+      hbPk2sig: 'hb-pk2sig',
+      hbSig: 'hb-sig',
+    };
+  }
+
+  // eslint-disable-next-line camelcase
+  static from_obj_for_encoding(data: Record<string, any>): HbProofFields {
+    /* eslint-disable dot-notation */
+    return new HbProofFields({
+      hbPk: data['hb-pk'],
+      hbPk1sig: data['hb-pk1sig'],
+      hbPk2: data['hb-pk2'],
+      hbPk2sig: data['hb-pk2sig'],
+      hbSig: data['hb-sig'],
+    });
+    /* eslint-enable dot-notation */
+  }
+}
+
 /**
  * A health check response.
  */
@@ -4195,6 +4357,13 @@ export class Transaction extends BaseModel {
    */
   public group?: Uint8Array;
 
+  /**
+   * Fields for a heartbeat transaction.
+   * Definition:
+   * data/transactions/heartbeat.go : HeartbeatTxnFields
+   */
+  public heartbeatTransaction?: TransactionHeartbeat;
+
   /**
    * Transaction ID
    */
@@ -4295,6 +4464,7 @@ export class Transaction extends BaseModel {
    * * (afrz) asset-freeze-transaction
    * * (appl) application-transaction
    * * (stpf) state-proof-transaction
+   * * (hb) heartbeat-transaction
    */
   public txType?: string;
 
@@ -4334,6 +4504,9 @@ export class Transaction extends BaseModel {
    * @param group - (grp) Base64 encoded byte array of a sha512/256 digest. When present indicates
    * that this transaction is part of a transaction group and the value is the
    * sha512/256 hash of the transactions in that group.
+   * @param heartbeatTransaction - Fields for a heartbeat transaction.
+   * Definition:
+   * data/transactions/heartbeat.go : HeartbeatTxnFields
    * @param id - Transaction ID
    * @param innerTxns - Inner transactions produced by application execution.
    * @param intraRoundOffset - Offset into the round where this transaction was confirmed.
@@ -4373,6 +4546,7 @@ export class Transaction extends BaseModel {
    * * (afrz) asset-freeze-transaction
    * * (appl) application-transaction
    * * (stpf) state-proof-transaction
+   * * (hb) heartbeat-transaction
    */
   constructor({
     fee,
@@ -4393,6 +4567,7 @@ export class Transaction extends BaseModel {
     genesisId,
     globalStateDelta,
     group,
+    heartbeatTransaction,
     id,
     innerTxns,
     intraRoundOffset,
@@ -4428,6 +4603,7 @@ export class Transaction extends BaseModel {
     genesisId?: string;
     globalStateDelta?: EvalDeltaKeyValue[];
     group?: string | Uint8Array;
+    heartbeatTransaction?: TransactionHeartbeat;
     id?: string;
     innerTxns?: Transaction[];
     intraRoundOffset?: number | bigint;
@@ -4470,6 +4646,7 @@ export class Transaction extends BaseModel {
       typeof group === 'string'
         ? new Uint8Array(Buffer.from(group, 'base64'))
         : group;
+    this.heartbeatTransaction = heartbeatTransaction;
     this.id = id;
     this.innerTxns = innerTxns;
     this.intraRoundOffset = intraRoundOffset;
@@ -4512,6 +4689,7 @@ export class Transaction extends BaseModel {
       genesisId: 'genesis-id',
       globalStateDelta: 'global-state-delta',
       group: 'group',
+      heartbeatTransaction: 'heartbeat-transaction',
       id: 'id',
       innerTxns: 'inner-txns',
       intraRoundOffset: 'intra-round-offset',
@@ -4590,6 +4768,12 @@ export class Transaction extends BaseModel {
             )
           : undefined,
       group: data['group'],
+      heartbeatTransaction:
+        typeof data['heartbeat-transaction'] !== 'undefined'
+          ? TransactionHeartbeat.from_obj_for_encoding(
+              data['heartbeat-transaction']
+            )
+          : undefined,
       id: data['id'],
       innerTxns:
         typeof data['inner-txns'] !== 'undefined'
@@ -5094,6 +5278,116 @@ export class TransactionAssetTransfer extends BaseModel {
   }
 }
 
+/**
+ * Fields for a heartbeat transaction.
+ * Definition:
+ * data/transactions/heartbeat.go : HeartbeatTxnFields
+ */
+export class TransactionHeartbeat extends BaseModel {
+  /**
+   * (hbad) HbAddress is the account this txn is proving onlineness for.
+   */
+  public hbAddress: string;
+
+  /**
+   * (hbkd) HbKeyDilution must match HbAddress account's current KeyDilution.
+   */
+  public hbKeyDilution: number | bigint;
+
+  /**
+   * (hbprf) HbProof is a signature using HeartbeatAddress's partkey, thereby showing
+   * it is online.
+   */
+  public hbProof: HbProofFields;
+
+  /**
+   * (hbsd) HbSeed must be the block seed for the this transaction's firstValid
+   * block.
+   */
+  public hbSeed: Uint8Array;
+
+  /**
+   * (hbvid) HbVoteID must match the HbAddress account's current VoteID.
+   */
+  public hbVoteId: Uint8Array;
+
+  /**
+   * Creates a new `TransactionHeartbeat` object.
+   * @param hbAddress - (hbad) HbAddress is the account this txn is proving onlineness for.
+   * @param hbKeyDilution - (hbkd) HbKeyDilution must match HbAddress account's current KeyDilution.
+   * @param hbProof - (hbprf) HbProof is a signature using HeartbeatAddress's partkey, thereby showing
+   * it is online.
+   * @param hbSeed - (hbsd) HbSeed must be the block seed for the this transaction's firstValid
+   * block.
+   * @param hbVoteId - (hbvid) HbVoteID must match the HbAddress account's current VoteID.
+   */
+  constructor({
+    hbAddress,
+    hbKeyDilution,
+    hbProof,
+    hbSeed,
+    hbVoteId,
+  }: {
+    hbAddress: string;
+    hbKeyDilution: number | bigint;
+    hbProof: HbProofFields;
+    hbSeed: string | Uint8Array;
+    hbVoteId: string | Uint8Array;
+  }) {
+    super();
+    this.hbAddress = hbAddress;
+    this.hbKeyDilution = hbKeyDilution;
+    this.hbProof = hbProof;
+    this.hbSeed =
+      typeof hbSeed === 'string'
+        ? new Uint8Array(Buffer.from(hbSeed, 'base64'))
+        : hbSeed;
+    this.hbVoteId =
+      typeof hbVoteId === 'string'
+        ? new Uint8Array(Buffer.from(hbVoteId, 'base64'))
+        : hbVoteId;
+
+    this.attribute_map = {
+      hbAddress: 'hb-address',
+      hbKeyDilution: 'hb-key-dilution',
+      hbProof: 'hb-proof',
+      hbSeed: 'hb-seed',
+      hbVoteId: 'hb-vote-id',
+    };
+  }
+
+  // eslint-disable-next-line camelcase
+  static from_obj_for_encoding(
+    data: Record<string, any>
+  ): TransactionHeartbeat {
+    /* eslint-disable dot-notation */
+    if (typeof data['hb-address'] === 'undefined')
+      throw new Error(
+        `Response is missing required field 'hb-address': ${data}`
+      );
+    if (typeof data['hb-key-dilution'] === 'undefined')
+      throw new Error(
+        `Response is missing required field 'hb-key-dilution': ${data}`
+      );
+    if (typeof data['hb-proof'] === 'undefined')
+      throw new Error(`Response is missing required field 'hb-proof': ${data}`);
+    if (typeof data['hb-seed'] === 'undefined')
+      throw new Error(`Response is missing required field 'hb-seed': ${data}`);
+    if (typeof data['hb-vote-id'] === 'undefined')
+      throw new Error(
+        `Response is missing required field 'hb-vote-id': ${data}`
+      );
+    return new TransactionHeartbeat({
+      hbAddress: data['hb-address'],
+      hbKeyDilution: data['hb-key-dilution'],
+      hbProof: HbProofFields.from_obj_for_encoding(data['hb-proof']),
+      hbSeed: data['hb-seed'],
+      hbVoteId: data['hb-vote-id'],
+    });
+    /* eslint-enable dot-notation */
+  }
+}
+
 /**
  * Fields for a keyreg transaction.
  * Definition:
diff --git a/src/transaction.ts b/src/transaction.ts
index 884de26..20ee895 100644
--- a/src/transaction.ts
+++ b/src/transaction.ts
@@ -19,6 +19,8 @@ import AnyTransaction, {
   EncodedSignedTransaction,
   EncodedMultisig,
   EncodedLogicSig,
+  EncodedHeartbeatFields,
+  EncodedHeartbeatProof,
 } from './types/transactions';
 import { Address } from './types/address';
 
@@ -38,6 +40,100 @@ const KEYREG_STATE_PROOF_KEY_LENGTH = 64;
 type AnyTransactionWithParams = MustHaveSuggestedParams<AnyTransaction>;
 type AnyTransactionWithParamsInline = MustHaveSuggestedParamsInline<AnyTransaction>;
 
+/**
+ * Object representing a heartbeat proof
+ */
+class HeartbeatProof {
+  s: Buffer;
+  p: Buffer;
+  p2: Buffer;
+  p1s: Buffer;
+  p2s: Buffer;
+
+  constructor(sig: Buffer, pk: Buffer, pk2: Buffer, p1s: Buffer, p2s: Buffer) {
+    this.s = sig;
+    this.p = pk;
+    this.p2 = pk2;
+    this.p1s = p1s;
+    this.p2s = p2s;
+  }
+
+  // eslint-disable-next-line camelcase
+  static from_obj_for_encoding(
+    encHbFields: EncodedHeartbeatProof
+  ): HeartbeatProof {
+    const proof = Object.create(this.prototype) as HeartbeatProof;
+    proof.s = encHbFields.s;
+    proof.p = encHbFields.p;
+    proof.p2 = encHbFields.p2;
+    proof.p1s = encHbFields.p1s;
+    proof.p2s = encHbFields.p2s;
+    return proof;
+  }
+
+  // eslint-disable-next-line camelcase
+  get_obj_for_encoding() {
+    const fields: EncodedHeartbeatProof = {
+      s: this.s,
+      p: this.p,
+      p2: this.p2,
+      p1s: this.p1s,
+      p2s: this.p2s,
+    };
+    return fields;
+  }
+}
+
+/**
+ * Object representing a heartbeat transaction
+ */
+class HeartbeatFields {
+  a: string | Address;
+  prf: HeartbeatProof;
+  sd: Buffer;
+  vid: Buffer;
+  kd: number;
+
+  constructor(
+    addr: string | Address,
+    proof: EncodedHeartbeatProof,
+    seed: Buffer,
+    voteID: Buffer,
+    kd: number
+  ) {
+    this.a = addr;
+    this.prf = HeartbeatProof.from_obj_for_encoding(proof);
+    this.sd = seed;
+    this.vid = voteID;
+    this.kd = kd;
+  }
+
+  // eslint-disable-next-line camelcase
+  static from_obj_for_encoding(
+    encHbFields: EncodedHeartbeatFields
+  ): HeartbeatFields {
+    const fields = Object.create(this.prototype) as HeartbeatFields;
+    fields.a = address.decodeAddress(address.encodeAddress(encHbFields.a));
+    fields.prf = HeartbeatProof.from_obj_for_encoding(encHbFields.prf);
+    fields.sd = Buffer.from(encHbFields.sd);
+    fields.vid = Buffer.from(encHbFields.vid);
+    fields.kd = encHbFields.kd;
+    return fields;
+  }
+
+  // eslint-disable-next-line camelcase
+  get_obj_for_encoding() {
+    const fields: EncodedHeartbeatFields = {
+      a: Buffer.from((this.a as Address).publicKey),
+      prf: this.prf.get_obj_for_encoding(),
+      sd: this.sd,
+      vid: this.vid,
+      kd: this.kd,
+    };
+    return fields;
+  }
+}
+
 /**
  * A modified version of the transaction params. Represents the internal structure that the Transaction class uses
  * to store inputted transaction objects.
@@ -62,6 +158,7 @@ interface TransactionStorageStructure
     | 'appAccounts'
     | 'suggestedParams'
     | 'reKeyTo'
+    | 'heartbeatFields' // redefine type to silent TS error about 'string | Address' and 'string' incompatibility
   > {
   from: string | Address;
   to: string | Address;
@@ -117,6 +214,7 @@ interface TransactionStorageStructure
   stateProofType?: number | bigint;
   stateProof?: Uint8Array;
   stateProofMessage?: Uint8Array;
+  heartbeatFields?: HeartbeatFields;
 }
 
 function getKeyregKey(
@@ -209,6 +307,7 @@ export class Transaction implements TransactionStorageStructure {
   stateProofType?: number | bigint;
   stateProof?: Uint8Array;
   stateProofMessage?: Uint8Array;
+  heartbeatFields?: HeartbeatFields;
 
   constructor({ ...transaction }: AnyTransaction) {
     // Populate defaults
@@ -574,6 +673,10 @@ export class Transaction implements TransactionStorageStructure {
     } else {
       txn.stateProof = new Uint8Array(0);
     }
+    if (txn.heartbeatFields !== undefined) {
+      if (txn.heartbeatFields.constructor !== HeartbeatFields)
+        throw Error('heartbeatFields must be a HeartbeatFields.');
+    }
   }
 
   // eslint-disable-next-line camelcase
@@ -918,6 +1021,28 @@ export class Transaction implements TransactionStorageStructure {
       if (txn.grp === undefined) delete txn.grp;
       return txn;
     }
+    if (this.type === 'hb') {
+      // heartbeat txn
+      const txn: EncodedTransaction = {
+        fee: this.fee,
+        fv: this.firstRound,
+        lv: this.lastRound,
+        note: Buffer.from(this.note),
+        snd: Buffer.from(this.from.publicKey),
+        type: this.type,
+        gen: this.genesisID,
+        gh: this.genesisHash,
+        lx: Buffer.from(this.lease),
+        hb: this.heartbeatFields.get_obj_for_encoding(),
+      };
+      // allowed zero values
+      if (!txn.note.length) delete txn.note;
+      if (!txn.lx.length) delete txn.lx;
+      if (!txn.fee) delete txn.fee;
+      if (!txn.gen) delete txn.gen;
+      if (txn.grp === undefined) delete txn.grp;
+      return txn;
+    }
 
     return undefined;
   }
@@ -1105,6 +1230,12 @@ export class Transaction implements TransactionStorageStructure {
       if (txnForEnc.spmsg !== undefined) {
         txn.stateProofMessage = txnForEnc.spmsg;
       }
+    } else if (txnForEnc.type === 'hb') {
+      if (txnForEnc.hb !== undefined) {
+        txn.heartbeatFields = HeartbeatFields.from_obj_for_encoding(
+          txnForEnc.hb
+        );
+      }
     }
     return txn;
   }
diff --git a/src/types/transactions/base.ts b/src/types/transactions/base.ts
index fb357a6..f3026bb 100644
--- a/src/types/transactions/base.ts
+++ b/src/types/transactions/base.ts
@@ -37,6 +37,11 @@ export enum TransactionType {
    * State proof transaction
    */
   stpf = 'stpf',
+
+  /**
+   * Heartbeat transaction
+   */
+  hb = 'hb',
 }
 
 export function isTransactionType(s: string): s is TransactionType {
@@ -47,7 +52,8 @@ export function isTransactionType(s: string): s is TransactionType {
     s === TransactionType.axfer ||
     s === TransactionType.afrz ||
     s === TransactionType.appl ||
-    s === TransactionType.stpf
+    s === TransactionType.stpf ||
+    s === TransactionType.hb
   );
 }
 
@@ -152,6 +158,14 @@ export interface BoxReference {
   name: Uint8Array;
 }
 
+interface heartbeatFields {
+  a: string;
+  prf: Uint8Array;
+  sd: Uint8Array;
+  vid: Uint8Array;
+  kd: number;
+}
+
 /**
  * A full list of all available transaction parameters
  *
@@ -432,4 +446,9 @@ export interface TransactionParams {
    * Byte array containing the state proof message.
    */
   stateProofMessage?: Uint8Array;
+
+  /**
+   * HeartbeatFields fields
+   */
+  heartbeatFields?: heartbeatFields;
 }
diff --git a/src/types/transactions/encoded.ts b/src/types/transactions/encoded.ts
index 7864e4e..625851d 100644
--- a/src/types/transactions/encoded.ts
+++ b/src/types/transactions/encoded.ts
@@ -97,6 +97,66 @@ export interface EncodedBoxReference {
   n: Uint8Array;
 }
 
+/**
+ * A structure for an encoded heartbeat proof fields object
+ */
+export interface EncodedHeartbeatProof {
+  /**
+   * Sig
+   */
+  s: Buffer;
+
+  /**
+   * PK
+   */
+  p: Buffer;
+
+  /**
+   * PK2
+   */
+  p2: Buffer;
+
+  /**
+   * PK1Sig
+   */
+  p1s: Buffer;
+
+  /**
+   * PK2Sig
+   */
+  p2s: Buffer;
+}
+
+/**
+ * A structure for an encoded heartbeat fields object
+ */
+export interface EncodedHeartbeatFields {
+  /**
+   * HbAddress
+   */
+  a: Buffer;
+
+  /**
+   * HbProof
+   */
+  prf: EncodedHeartbeatProof;
+
+  /**
+   * HbSeed
+   */
+  sd: Buffer;
+
+  /**
+   * HbVoteID
+   */
+  vid: Buffer;
+
+  /**
+   * HbKeyDilution
+   */
+  kd: number;
+}
+
 /**
  * A rough structure for the encoded transaction object. Every property is labelled with its associated Transaction type property
  */
@@ -330,6 +390,11 @@ export interface EncodedTransaction {
    * stateProofMessage
    */
   spmsg?: Buffer;
+
+  /**
+   * heartbeat fields
+   */
+  hb?: EncodedHeartbeatFields;
 }
 
 export interface EncodedSubsig {
diff --git a/src/types/transactions/heartbeat.ts b/src/types/transactions/heartbeat.ts
new file mode 100644
index 0000000..3f7daa3
--- /dev/null
+++ b/src/types/transactions/heartbeat.ts
@@ -0,0 +1,14 @@
+import { TransactionType, TransactionParams } from './base';
+import { ConstructTransaction } from './builder';
+
+type SpecificParameters = Pick<TransactionParams, 'heartbeatFields'>;
+
+interface Overwrites {
+  type?: TransactionType.hb;
+}
+
+type HeartbeatTransaction = ConstructTransaction<
+  SpecificParameters,
+  Overwrites
+>;
+export default HeartbeatTransaction;
diff --git a/src/types/transactions/index.ts b/src/types/transactions/index.ts
index fc4d854..3d41bd9 100644
--- a/src/types/transactions/index.ts
+++ b/src/types/transactions/index.ts
@@ -17,6 +17,7 @@ import {
   ApplicationNoOpTransaction as AppNoOpTxn,
 } from './application';
 import StateProofTxn from './stateproof';
+import HeartbeatTxn from './heartbeat';
 
 // Utilities
 export {
@@ -51,6 +52,7 @@ export {
   ApplicationNoOpTransaction as AppNoOpTxn,
 } from './application';
 export { default as StateProofTxn } from './stateproof';
+export { default as HeartbeatTransaction } from './heartbeat';
 
 // All possible transaction types
 type AnyTransaction =
@@ -68,5 +70,6 @@ type AnyTransaction =
   | AppCloseOutTxn
   | AppClearStateTxn
   | AppNoOpTxn
-  | StateProofTxn;
+  | StateProofTxn
+  | HeartbeatTxn;
 export default AnyTransaction;


Test patch:
diff --git a/tests/5.Transaction.js b/tests/5.Transaction.js
index 7069d4b..431b38c 100644
--- a/tests/5.Transaction.js
+++ b/tests/5.Transaction.js
@@ -367,10 +367,12 @@ describe('Sign', () => {
         stateProofMessage: new Uint8Array([0, 0, 0, 0]),
       };
       const expectedTxn = new algosdk.Transaction(o);
+      // eslint-disable-next-line no-console
       console.log(
         `${expectedTxn.stateProofType} ${expectedTxn.stateProofMessage} ${expectedTxn.stateProof} ${expectedTxn.type}`
       );
       const encRep = expectedTxn.get_obj_for_encoding();
+      // eslint-disable-next-line no-console
       console.log(
         `${encRep.sptype} ${encRep.spmsg} ${encRep.sp} ${encRep.type}`
       );
@@ -635,6 +637,32 @@ describe('Sign', () => {
       assert.deepStrictEqual(reencRep, encRep);
     });
 
+    it('should correctly serialize and deserialize heartbeat transaction', () => {
+      const golden = new Uint8Array(
+        Buffer.from(
+          'gqRsc2lngaFsxAYLMSAyAxKjdHhuhqJmdmqiZ2jEIP9SQzAGyec/v8omzEOW3/GIM+a7bvPaU5D/ohX7qjFtomhihaFhxCBsU6oqjVx2U65owbsX9/6N7/YCmul+O3liZ0fO2L75/KJrZGSjcHJmhaFwxCAM1TyIrIbgm+yPLT9so6VDI3rKl33t4c4RSGJv6G12eaNwMXPEQBETln14zJzQ1Mb/SNjmDNl0fyQ4DPBQZML8iTEbhqBj+YDAgpNSEduWj7OuVkCSQMq4N/Er/+2HfKUHu//spgOicDLEIB9c5n7WgG+5aOdjfBmuxH3z4TYiQzDVYKjBLhv4IkNfo3Ayc8RAeKpQ+o/GJyGCH0I4f9luN0i7BPXlMlaJAuXLX5Ng8DTN0vtZtztjqYfkwp1cVOYPu+Fce3aIdJHVoUDaJaMIDqFzxEBQN41y5zAZhYHQWf2wWF6CGboqQk6MxDcQ76zXHvVtzrAPUWXZDt4IB8Ha1z+54Hc6LmEoG090pk0IYs+jLN8HonNkxCCPVPjiD5O7V0c3P/SVsHmED7slwllta7c92WiKwnvgoqN2aWTEIHBy8sOi/V0YKXJw8VtW40MbqhtUyO9HC9m/haf84xiGomx2dKNzbmTEIDAp2wPDnojyy8tTgb3sMH++26D5+l7nHZmyRvzFfLsOpHR5cGWiaGI=',
+          'base64'
+        )
+      );
+
+      const decTxn = algosdk.decodeSignedTransaction(golden);
+      const signed = {
+        lsig: decTxn.lsig,
+        txn: decTxn.txn.get_obj_for_encoding(),
+      };
+      const reencRep = algosdk.encodeObj(signed);
+      assert.deepStrictEqual(reencRep, golden);
+      const hbAddress =
+        'NRJ2UKUNLR3FHLTIYG5RP576RXX7MAU25F7DW6LCM5D45WF67H6EFQMWNM';
+
+      assert.deepStrictEqual(decTxn.txn.type, algosdk.TransactionType.hb);
+      assert.deepStrictEqual(
+        algosdk.encodeAddress(decTxn.txn.heartbeatFields.a.publicKey),
+        hbAddress
+      );
+      assert.deepStrictEqual(decTxn.txn.heartbeatFields?.kd, 100);
+    });
+
     it('reserializes correctly no genesis ID', () => {
       const o = {
         from: 'XMHLMNAVJIMAW2RHJXLXKKK4G3J3U6VONNO3BTAQYVDC3MHTGDP3J5OCRU',
diff --git a/tests/cucumber/steps/steps.js b/tests/cucumber/steps/steps.js
index c14fad9..06371c6 100644
--- a/tests/cucumber/steps/steps.js
+++ b/tests/cucumber/steps/steps.js
@@ -4,6 +4,7 @@ const assert = require('assert');
 const fs = require('fs');
 const path = require('path');
 
+const { skip } = require('node:test');
 const algosdk = require('../../../src/index');
 const nacl = require('../../../src/nacl/naclWrappers');
 
@@ -1939,7 +1940,15 @@ module.exports = function getSteps(options) {
   let anyBlockResponse;
 
   When('we make any Get Block call', async function () {
-    anyBlockResponse = await this.v2Client.block(1).do();
+    const req = this.v2Client.block(1);
+    if (responseFormat === 'json') {
+      // for json responses, we need to set the format query param and provide a custom decoder
+      // because the default block request only supports msgpack
+      req.query.format = responseFormat;
+      req.prepare = (body) =>
+        algosdk.modelsv2.BlockResponse.from_obj_for_encoding(body);
+    }
+    anyBlockResponse = await req.do();
   });
 
   Then(
@@ -1952,6 +1961,25 @@ module.exports = function getSteps(options) {
     }
   );
 
+  Then(
+    'the parsed Get Block response should have heartbeat address {string}',
+    (hbAddress) => {
+      if (responseFormat === 'json') {
+        // cannot properly decode json response (base32 addresses) into transaction objects so skip
+        skip();
+        return;
+      }
+      const enc = anyBlockResponse.block.txns[0].txn;
+      // txn from a block does not have genesis hash so inject to keep the decoding happy
+      enc.gh = '1234';
+      const txn = algosdk.Transaction.from_obj_for_encoding(enc);
+      const hbAddressString = algosdk.encodeAddress(
+        txn.heartbeatFields.a.publicKey
+      );
+      assert.strictEqual(hbAddress, hbAddressString);
+    }
+  );
+
   let anySuggestedTransactionsResponse;
 
   When('we make any Suggested Transaction Parameters call', async function () {
@@ -2691,6 +2719,26 @@ module.exports = function getSteps(options) {
     }
   );
 
+  Then(
+    'the parsed SearchForTransactions response should be valid on round {int} and the array should be of len {int} and the element at index {int} should have hbaddress {string}',
+    (round, length, idx, hbAddress) => {
+      assert.strictEqual(
+        anySearchForTransactionsResponse['current-round'],
+        round
+      );
+      assert.strictEqual(
+        anySearchForTransactionsResponse.transactions.length,
+        length
+      );
+      assert.strictEqual(
+        anySearchForTransactionsResponse.transactions[idx][
+          'heartbeat-transaction'
+        ]['hb-address'],
+        hbAddress
+      );
+    }
+  );
+
   let anySearchForAssetsResponse;
 
   When('we make any SearchForAssets call', async function () {
diff --git a/tests/cucumber/unit.tags b/tests/cucumber/unit.tags
index 482d9f4..76e6d2c 100644
--- a/tests/cucumber/unit.tags
+++ b/tests/cucumber/unit.tags
@@ -2,6 +2,7 @@
 @unit.abijson.byname
 @unit.algod
 @unit.algod.ledger_refactoring
+@unit.algod.heartbeat.msgp
 @unit.applications
 @unit.applications.boxes
 @unit.atomic_transaction_composer
@@ -13,6 +14,7 @@
 @unit.indexer
 @unit.indexer.ledger_refactoring
 @unit.indexer.logs
+@unit.indexer.heartbeat
 @unit.offline
 @unit.program_sanity_check
 @unit.ready