Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Incentives: Support heartbeat transaction #915

Merged
merged 5 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading