Skip to content

Commit

Permalink
Incentives: Support heartbeat transaction (#915)
Browse files Browse the repository at this point in the history
  • Loading branch information
algorandskiy authored Jan 14, 2025
1 parent 0d1dd73 commit d786559
Show file tree
Hide file tree
Showing 6 changed files with 327 additions and 2 deletions.
168 changes: 168 additions & 0 deletions src/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { Address } from './encoding/address.js';
import { Encodable, Schema } from './encoding/encoding.js';
import {
AddressSchema,
Uint64Schema,
ByteArraySchema,
FixedLengthByteArraySchema,
NamedMapSchema,
allOmitEmpty,
} from './encoding/schema/index.js';

export class HeartbeatProof implements Encodable {
public static readonly encodingSchema = new NamedMapSchema(
allOmitEmpty([
{
key: 's', // Sig
valueSchema: new FixedLengthByteArraySchema(64),
},
{
key: 'p', // PK
valueSchema: new FixedLengthByteArraySchema(32),
},
{
key: 'p2', // PK2
valueSchema: new FixedLengthByteArraySchema(32),
},
{
key: 'p1s', // PK1Sig
valueSchema: new FixedLengthByteArraySchema(64),
},
{
key: 'p2s', // PK2Sig
valueSchema: new FixedLengthByteArraySchema(64),
},
])
);

public sig: Uint8Array;

public pk: Uint8Array;

public pk2: Uint8Array;

public pk1Sig: Uint8Array;

public pk2Sig: Uint8Array;

public constructor(params: {
sig: Uint8Array;
pk: Uint8Array;
pk2: Uint8Array;
pk1Sig: Uint8Array;
pk2Sig: Uint8Array;
}) {
this.sig = params.sig;
this.pk = params.pk;
this.pk2 = params.pk2;
this.pk1Sig = params.pk1Sig;
this.pk2Sig = params.pk2Sig;
}

// eslint-disable-next-line class-methods-use-this
public getEncodingSchema(): Schema {
return HeartbeatProof.encodingSchema;
}

public toEncodingData(): Map<string, unknown> {
return new Map<string, unknown>([
['s', this.sig],
['p', this.pk],
['p2', this.pk2],
['p1s', this.pk1Sig],
['p2s', this.pk2Sig],
]);
}

public static fromEncodingData(data: unknown): HeartbeatProof {
if (!(data instanceof Map)) {
throw new Error(`Invalid decoded HeartbeatProof: ${data}`);
}
return new HeartbeatProof({
sig: data.get('s'),
pk: data.get('p'),
pk2: data.get('p2'),
pk1Sig: data.get('p1s'),
pk2Sig: data.get('p2s'),
});
}
}

export class Heartbeat implements Encodable {
public static readonly encodingSchema = new NamedMapSchema(
allOmitEmpty([
{
key: 'a', // HbAddress
valueSchema: new AddressSchema(),
},
{
key: 'prf', // HbProof
valueSchema: HeartbeatProof.encodingSchema,
},
{
key: 'sd', // HbSeed
valueSchema: new ByteArraySchema(),
},
{
key: 'vid', // HbVoteID
valueSchema: new FixedLengthByteArraySchema(32),
},
{
key: 'kd', // HbKeyDilution
valueSchema: new Uint64Schema(),
},
])
);

public address: Address;

public proof: HeartbeatProof;

public seed: Uint8Array;

public voteID: Uint8Array;

public keyDilution: bigint;

public constructor(params: {
address: Address;
proof: HeartbeatProof;
seed: Uint8Array;
voteID: Uint8Array;
keyDilution: bigint;
}) {
this.address = params.address;
this.proof = params.proof;
this.seed = params.seed;
this.voteID = params.voteID;
this.keyDilution = params.keyDilution;
}

// eslint-disable-next-line class-methods-use-this
public getEncodingSchema(): Schema {
return Heartbeat.encodingSchema;
}

public toEncodingData(): Map<string, unknown> {
return new Map<string, unknown>([
['a', this.address],
['prf', this.proof.toEncodingData()],
['sd', this.seed],
['vid', this.voteID],
['kd', this.keyDilution],
]);
}

public static fromEncodingData(data: unknown): Heartbeat {
if (!(data instanceof Map)) {
throw new Error(`Invalid decoded Heartbeat: ${data}`);
}
return new Heartbeat({
address: data.get('a'),
proof: HeartbeatProof.fromEncodingData(data.get('prf')),
seed: data.get('sd'),
voteID: data.get('vid'),
keyDilution: data.get('kd'),
});
}
}
46 changes: 46 additions & 0 deletions src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import {
KeyRegistrationTransactionParams,
ApplicationCallTransactionParams,
StateProofTransactionParams,
HeartbeatTransactionParams,
} from './types/transactions/base.js';
import { StateProof, StateProofMessage } from './stateproof.js';
import { Heartbeat, HeartbeatProof } from './heartbeat.js';
import * as utils from './utils/utils.js';

const ALGORAND_TRANSACTION_LENGTH = 52;
Expand Down Expand Up @@ -248,6 +250,14 @@ export interface StateProofTransactionFields {
readonly message?: StateProofMessage;
}

export interface HeartbeatTransactionFields {
readonly address: Address;
readonly proof: HeartbeatProof;
readonly seed: Uint8Array;
readonly voteID: Uint8Array;
readonly keyDilution: bigint;
}

/**
* Transaction enables construction of Algorand transactions
* */
Expand Down Expand Up @@ -438,6 +448,8 @@ export class Transaction implements encoding.Encodable {
key: 'spmsg',
valueSchema: new OptionalSchema(StateProofMessage.encodingSchema),
},
// Heartbeat
{ key: 'hb', valueSchema: new OptionalSchema(Heartbeat.encodingSchema) },
])
);

Expand Down Expand Up @@ -466,6 +478,7 @@ export class Transaction implements encoding.Encodable {
public readonly assetFreeze?: AssetFreezeTransactionFields;
public readonly applicationCall?: ApplicationTransactionFields;
public readonly stateProof?: StateProofTransactionFields;
public readonly heartbeat?: HeartbeatTransactionFields;

constructor(params: TransactionParams) {
if (!isTransactionType(params.type)) {
Expand Down Expand Up @@ -506,6 +519,7 @@ export class Transaction implements encoding.Encodable {
if (params.assetFreezeParams) fieldsPresent.push(TransactionType.afrz);
if (params.appCallParams) fieldsPresent.push(TransactionType.appl);
if (params.stateProofParams) fieldsPresent.push(TransactionType.stpf);
if (params.heartbeatParams) fieldsPresent.push(TransactionType.hb);

if (fieldsPresent.length !== 1) {
throw new Error(
Expand Down Expand Up @@ -701,6 +715,16 @@ export class Transaction implements encoding.Encodable {
};
}

if (params.heartbeatParams) {
this.heartbeat = new Heartbeat({
address: params.heartbeatParams.address,
proof: params.heartbeatParams.proof,
seed: params.heartbeatParams.seed,
voteID: params.heartbeatParams.voteID,
keyDilution: params.heartbeatParams.keyDilution,
});
}

// Determine fee
this.fee = utils.ensureUint64(params.suggestedParams.fee);

Expand Down Expand Up @@ -842,6 +866,18 @@ export class Transaction implements encoding.Encodable {
return data;
}

if (this.heartbeat) {
const heartbeat = new Heartbeat({
address: this.heartbeat.address,
proof: this.heartbeat.proof,
seed: this.heartbeat.seed,
voteID: this.heartbeat.voteID,
keyDilution: this.heartbeat.keyDilution,
});
data.set('hb', heartbeat.toEncodingData());
return data;
}

throw new Error(`Unexpected transaction type: ${this.type}`);
}

Expand Down Expand Up @@ -1006,6 +1042,16 @@ export class Transaction implements encoding.Encodable {
: undefined,
};
params.stateProofParams = stateProofParams;
} else if (params.type === TransactionType.hb) {
const heartbeat = Heartbeat.fromEncodingData(data.get('hb'));
const heartbeatParams: HeartbeatTransactionParams = {
address: heartbeat.address,
proof: heartbeat.proof,
seed: heartbeat.seed,
voteID: heartbeat.voteID,
keyDilution: heartbeat.keyDilution,
};
params.heartbeatParams = heartbeatParams;
} else {
const exhaustiveCheck: never = params.type;
throw new Error(`Unexpected transaction type: ${exhaustiveCheck}`);
Expand Down
44 changes: 43 additions & 1 deletion src/types/transactions/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Address } from '../../encoding/address.js';
import { StateProof, StateProofMessage } from '../../stateproof.js';
import { HeartbeatProof } from '../../heartbeat.js';

/**
* Enum for application transaction types.
Expand Down Expand Up @@ -38,6 +39,11 @@ export enum TransactionType {
* State proof transaction
*/
stpf = 'stpf',

/**
* Heartbeat transaction
*/
hb = 'hb',
}

/**
Expand All @@ -53,7 +59,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
);
}

Expand Down Expand Up @@ -466,6 +473,36 @@ export interface StateProofTransactionParams {
message?: StateProofMessage;
}

/**
* Contains heartbeat transaction parameters.
*/
export interface HeartbeatTransactionParams {
/*
* Account address this txn is proving onlineness for
*/
address: Address;

/**
* Signature using HeartbeatAddress's partkey, thereby showing it is online.
*/
proof: HeartbeatProof;

/**
* The block seed for the this transaction's firstValid block.
*/
seed: Uint8Array;

/**
* Must match the hbAddress account's current VoteID
*/
voteID: Uint8Array;

/**
* Must match hbAddress account's current KeyDilution.
*/
keyDilution: bigint;
}

/**
* A full list of all available transaction parameters
*
Expand Down Expand Up @@ -540,4 +577,9 @@ export interface TransactionParams {
* State proof transaction parameters. Only set if type is TransactionType.stpf
*/
stateProofParams?: StateProofTransactionParams;

/**
* Heartbeat transaction parameters. Only set if type is TransactionType.hb
*/
heartbeatParams?: HeartbeatTransactionParams;
}
24 changes: 24 additions & 0 deletions tests/5.Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,30 @@ describe('Sign', () => {
assert.deepStrictEqual(reencRep, encRep);
});

it('should correctly serialize and deserialize heartbeat transaction', () => {
const golden = algosdk.base64ToBytes(
'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='
);

const decTxn = algosdk.decodeMsgpack(golden, algosdk.SignedTransaction);
const prepTxn = algosdk.SignedTransaction.encodingSchema.prepareMsgpack(
decTxn.toEncodingData()
);
assert.ok(prepTxn instanceof Map && prepTxn.has('txn'));

const reencRep = algosdk.encodeMsgpack(decTxn);
assert.deepStrictEqual(reencRep, golden);
const hbAddress =
'NRJ2UKUNLR3FHLTIYG5RP576RXX7MAU25F7DW6LCM5D45WF67H6EFQMWNM';

assert.deepStrictEqual(decTxn.txn.type, algosdk.TransactionType.hb);
assert.deepStrictEqual(
decTxn.txn.heartbeat?.address.toString(),
hbAddress
);
assert.deepStrictEqual(decTxn.txn.heartbeat?.keyDilution, 100n);
});

it('reserializes correctly no genesis ID', () => {
const expectedTxn = new algosdk.Transaction({
type: algosdk.TransactionType.pay,
Expand Down
Loading

0 comments on commit d786559

Please sign in to comment.