diff --git a/src/backend-types/deso-types-custom.ts b/src/backend-types/deso-types-custom.ts index 32e4366..bd1204b 100644 --- a/src/backend-types/deso-types-custom.ts +++ b/src/backend-types/deso-types-custom.ts @@ -247,6 +247,16 @@ export enum TransactionType { AccessGroup = 'ACCESS_GROUP', AccessGroupMembers = 'ACCESS_GROUP_MEMBERS', NewMessage = 'NEW_MESSAGE', + RegisterAsValidator = 'REGISTER_AS_VALIDATOR', + UnregisterAsValidator = 'UNREGISTER_AS_VALIDATOR', + Stake = 'STAKE', + Unstake = 'UNSTAKE', + UnlockStake = 'UNLOCK_STAKE', + UnjailValidator = 'UNJAIL_VALIDATOR', + CoinLockup = 'COIN_LOCKUP', + UpdateCoinLockupParams = 'UPDATE_COIN_LOCKUP_PARAMS', + CoinLockupTransfer = 'COIN_LOCKUP_TRANSFER', + CoinUnlock = 'COIN_UNLOCK', } export interface IdentityDeriveParams { diff --git a/src/backend-types/deso-types.ts b/src/backend-types/deso-types.ts index 5643b74..c9bef32 100644 --- a/src/backend-types/deso-types.ts +++ b/src/backend-types/deso-types.ts @@ -4616,6 +4616,42 @@ export interface AccessGroupMemberLimitMapItem { OpCount: number; } +export type StakeLimitMapItem = { + ValidatorPublicKeyBase58Check: string; + StakeLimit: string; // Hex string +}; + +export type UnstakeLimitMapItem = { + ValidatorPublicKeyBase58Check: string; + UnstakeLimit: string; // Hex string +}; + +export type UnlockStakeLimitMapItem = { + ValidatorPublicKeyBase58Check: string; + OpCount: number; +}; + +export enum LockupLimitScopeType { + ANY = 'AnyCoins', + SCOPED = 'ScopedCoins', +} + +export enum LockupLimitOperationString { + ANY = 'Any', + COIN_LOCKUP = 'CoinLockup', + UPDATE_COIN_LOCKUP_YIELD_CURVE = 'UpdateCoinLockupYieldCurve', + UPDATE_COIN_LOCKUP_TRANSFER_RESTRICTIONS = 'UpdateCoinLockupTransferRestrictions', + COIN_LOCKUP_TRANSFER = 'CoinLockupTransferOperationString', + COIN_UNLOCK = 'CoinLockupUnlock', +} + +export type LockupLimitMapItem = { + ProfilePublicKeyBase58Check: string; + ScopeType: LockupLimitScopeType; + Operation: LockupLimitOperationString; + OpCount: number; +}; + // struct2ts:types/generated/types.TransactionSpendingLimitResponse export interface TransactionSpendingLimitResponse { GlobalDESOLimit?: number; @@ -4627,6 +4663,10 @@ export interface TransactionSpendingLimitResponse { AssociationLimitMap?: AssociationLimitMapItem[]; AccessGroupLimitMap?: AccessGroupLimitMapItem[]; AccessGroupMemberLimitMap?: AccessGroupMemberLimitMapItem[]; + StakeLimitMap?: StakeLimitMapItem[]; + UnstakeLimitMap?: UnstakeLimitMapItem[]; + UnlockStakeLimitMap?: UnlockStakeLimitMapItem[]; + LockupLimitMap?: LockupLimitMapItem[]; IsUnlimited?: boolean; } @@ -5587,3 +5627,174 @@ export interface GetVideoStatusResponse { } export type DiamondLevelString = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8'; + +export interface RegisterAsValidatorRequest { + TransactorPublicKeyBase58Check: string; + Domains: string[]; + DelegatedStakeCommissionBasisPoints: number; + DisableDelegatedStake: boolean; + VotingPublicKey: string; + VotingAuthorization: string; + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface UnregisterAsValidatorRequest { + TransactorPublicKeyBase58Check: string; + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface UnjailValidatorRequest { + TransactorPublicKeyBase58Check: string; + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface ValidatorTxnResponse { + SpendAmountNanos: number; + TotalInputNanos: number; + ChangeAmountNanos: number; + FeeNanos: number; + Transaction: MsgDeSoTxn; + TransactionHex: string; + TxnHashHex: string; +} + +export interface ValidatorResponse { + ValidatorPublicKeyBase58Check: string; + Domains: string[]; + DisableDelegatedStake: boolean; + VotingPublicKey: string; + VotingAuthorization: string; + TotalStakeAmountNanos: string; // HEX STRING + Status: string; + LastActiveAtEpochNumber: number; + JailedAtEpochNumber: number; + ExtraData: Record; +} + +export enum StakeRewardMethod { + PayToBalance = 'PAY_TO_BALANCE', + Restake = 'RESTAKE', +} + +export interface StakeRequest { + TransactorPublicKeyBase58Check: string; + ValidatorPublicKeyBase58Check: string; + RewardMethod: StakeRewardMethod; + StakeAmountNanos: string; // HEX STRING + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface UnstakeRequest { + TransactorPublicKeyBase58Check: string; + ValidatorPublicKeyBase58Check: string; + UnstakeAmountNanos: string; // HEX STRING + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface UnlockStakeRequest { + TransactorPublicKeyBase58Check: string; + ValidatorPublicKeyBase58Check: string; + StartEpochNumber: number; + EndEpochNumber: number; + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface StakeTxnResponse { + SpendAmountNanos: number; + TotalInputNanos: number; + ChangeAmountNanos: number; + FeeNanos: number; + Transaction: MsgDeSoTxn; + TransactionHex: string; + TxnHashHex: string; +} + +export interface StakeEntryResponse { + StakerPublicKeyBase58Check: string; + ValidatorPublicKeyBase58Check: string; + RewardMethod: StakeRewardMethod; + StakeAmountNanos: string; // HEX string + ExtraData: Record; +} + +export interface LockedBalanceEntryResponse { + HODLerPublicKeyBase58Check: string; + ProfilePublicKeyBase58Check: string; + UnlockTimestampNanoSecs: number; + VestingEndTimestampNanoSecs: number; + BalanceBaseUnits: string; // HEX string + ProfileEntryResponse?: ProfileEntryResponse; + HODLerProfileEntryResponse?: ProfileEntryResponse; +} + +export interface LockupYieldCurvePointResponse { + ProfilePublicKeyBase58Check: string; + LockupDurationNanoSecs: number; + LockupYieldAPYBasisPoints: number; + ProfileEntryResponse?: ProfileEntryResponse; +} + +export interface CoinLockupRequest { + TransactorPublicKeyBase58Check: string; + ProfilePublicKeyBase58Check: string; + RecipientPublicKeyBase58Check: string; + UnlockTimestampNanoSecs: number; + VestingEndTimestampNanoSecs: number; + LockupAmountBaseUnits: string; // HEX string + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface UpdateCoinLockupParamsRequest { + TransactorPublicKeyBase58Check: string; + LockupYieldDurationNanoSecs: number; + LockupYieldAPYBasisPoints: number; + RemoveYieldCurvePoint: boolean; + NewLockupTransferRestrictions: boolean; + LockupTransferRestrictionStatus: string; // TODO: introduce TransferRestrictionStatusString enum and use everywhere. + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface CoinLockupTransferRequest { + TransactorPublicKeyBase58Check: string; + ProfilePublicKeyBase58Check: string; + RecipientPublicKeyBase58Check: string; + UnlockTimestampNanoSecs: number; + LockedCoinsToTransferBaseUnits: string; // HEX string + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface CoinUnlockRequest { + TransactorPublicKeyBase58Check: string; + ProfilePublicKeyBase58Check: string; + ExtraData: Record; + MinFeeRateNanosPerKB: number; + TransactionFees: TransactionFee[]; +} + +export interface CoinLockResponse { + SpendAmountNanos: number; + TotalInputNanos: number; + ChangeAmountNanos: number; + FeeNanos: number; + Transaction: MsgDeSoTxn; + TransactionHex: string; + TxnHashHex: string; +} diff --git a/src/identity/crypto-utils.ts b/src/identity/crypto-utils.ts index 7bcccd6..e24dbd3 100644 --- a/src/identity/crypto-utils.ts +++ b/src/identity/crypto-utils.ts @@ -78,6 +78,23 @@ export const uint64ToBufBigEndian = (uint: number) => { return new Uint8Array(result.reverse()); }; +export const varint64ToBuf = (int: number) => { + let ux = BigInt(int) << BigInt(1); + if (int < 0) { + ux = ~ux; + } + return uvarint64ToBuf(Number(ux)); +}; + +export const bufToVarint64 = (buffer: Uint8Array): [number, Uint8Array] => { + const [ux, n] = bufToUvarint64(buffer); + let x = BigInt(ux) >> BigInt(1); + if (ux & 1) { + x = ~x; + } + return [Number(x), n]; +}; + interface Base58CheckOptions { network: Network; } diff --git a/src/identity/derived-key-utils.ts b/src/identity/derived-key-utils.ts index 2d8895a..c3720d0 100644 --- a/src/identity/derived-key-utils.ts +++ b/src/identity/derived-key-utils.ts @@ -23,9 +23,9 @@ export async function generateDerivedKeyPayload( ) { const { BlockHeight } = await getAppState(); - // days * (24 hours / day) * (60 minutes / hour) * (1 block / 5 minutes) = blocks + // days * (24 hours / day) * (60 minutes / hour) * (60 seconds / minute) * (1 block / 1 second) = blocks const expirationBlockHeight = - BlockHeight + (numDaysBeforeExpiration * 24 * 60) / 5; + BlockHeight + numDaysBeforeExpiration * 24 * 60 * 60; const ownerPublicKeyBase58 = publicKeyToBase58Check(ownerKeys.public, { network, }); diff --git a/src/identity/error-types.ts b/src/identity/error-types.ts index 145b9bf..afddf52 100644 --- a/src/identity/error-types.ts +++ b/src/identity/error-types.ts @@ -1,4 +1,5 @@ export enum ERROR_TYPES { NO_MONEY = 'NO_MONEY', NO_PENDING_REQUEST = 'NO_PENDING_REQUEST', + IDENTITY_WINDOW_CLOSED = 'IDENTITY_WINDOW_CLOSED', } diff --git a/src/identity/identity.spec.ts b/src/identity/identity.spec.ts index 73414d4..77cc22a 100644 --- a/src/identity/identity.spec.ts +++ b/src/identity/identity.spec.ts @@ -1008,7 +1008,7 @@ describe('identity', () => { }); describe('loginWithAutoDerive()', () => { it('it stores the expected derive data when generating a local derived key payload', async () => { - const expectedExpirationBlock = 1294652; + const expectedExpirationBlock = 315603452; const expectedDerivePayload = { derivedPublicKeyBase58Check: 'BC1YLhKdgXgrZ1XkCzbmP6T9bumth2DgPwNjMksCAXe5kGU9LnxQtsX', diff --git a/src/identity/identity.ts b/src/identity/identity.ts index 21d1db7..d768cd7 100644 --- a/src/identity/identity.ts +++ b/src/identity/identity.ts @@ -48,7 +48,6 @@ import { NOTIFICATION_EVENTS, StorageProvider, type APIProvider, - type Deferred, type EtherscanTransactionsByAddressResponse, type IdentityConfiguration, type IdentityDerivePayload, @@ -60,6 +59,37 @@ import { type jwtAlgorithm, } from './types.js'; +class Deferred { + #resolve: (args: any) => void; + #reject: (args: any) => void; + event: NOTIFICATION_EVENTS; + status: 'pending' | 'settled' = 'pending'; + + constructor({ + resolve, + reject, + event, + }: { + resolve: (args: any) => void; + reject: (args: any) => void; + event: NOTIFICATION_EVENTS; + }) { + this.#reject = reject; + this.#resolve = resolve; + this.event = event; + } + + resolve(args: any) { + this.status = 'settled'; + return this.#resolve(args); + } + + reject(args: any) { + this.status = 'settled'; + return this.#reject(args); + } +} + export class Identity { /** * @private @@ -99,7 +129,7 @@ export class Identity { /** * @private */ - #pendingWindowRequest?: Deferred & { event: NOTIFICATION_EVENTS }; + #pendingWindowRequest?: Deferred; /** * @private @@ -490,7 +520,11 @@ export class Identity { // https://github.com/deso-protocol/deso-js/issues/1 if (!derivedKeyLogin) { return new Promise((resolve, reject) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ + resolve, + reject, + event, + }); this.#launchIdentity('log-in', { accessLevelRequest: 2, getFreeDeso, @@ -521,7 +555,11 @@ export class Identity { } return await new Promise((resolve, reject) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ + resolve, + reject, + event, + }); const authenticatedUserKeys = []; if (state.currentUser?.primaryDerivedKey) { @@ -597,7 +635,11 @@ export class Identity { ); return new Promise((resolve, reject) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ + resolve, + reject, + event, + }); // NOTE: We set this flag so that when the identity response is handled, // we know to let the login flow continue even if the user has no money to // authorize the key. It's up to the app to handle how the user gets @@ -637,7 +679,11 @@ export class Identity { return new Promise((resolve, reject) => { const activePublicKey = this.#getActivePublicKey(); const launchIdentity = (activePublicKey: string | null) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ + resolve, + reject, + event, + }); if (!activePublicKey) { this.#pendingWindowRequest.reject( new Error('cannot logout without an active public key') @@ -982,7 +1028,7 @@ export class Identity { return await new Promise((resolve, reject) => { const activePublicKey = this.#getActivePublicKey(); const launchIdentity = (activePublicKey: string | null) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ resolve, reject, event }); if (!activePublicKey) { this.#pendingWindowRequest.reject( @@ -1026,7 +1072,7 @@ export class Identity { return await new Promise((resolve, reject) => { const activePublicKey = this.#getActivePublicKey(); const launchIdentity = (activePublicKey: string | null) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ resolve, reject, event }); if (!activePublicKey) { this.#pendingWindowRequest.reject( @@ -1284,7 +1330,7 @@ export class Identity { this.#subscribers.forEach((s) => s({ event, ...state })); return await new Promise((resolve, reject) => { - this.#pendingWindowRequest = { resolve, reject, event }; + this.#pendingWindowRequest = new Deferred({ resolve, reject, event }); const params = { derive: true, @@ -1608,7 +1654,7 @@ export class Identity { /** * @private */ - #handlePostMessage(ev: MessageEvent) { + async #handlePostMessage(ev: MessageEvent) { if ( ev.origin !== this.#identityURI || ev.data.service !== IDENTITY_SERVICE_VALUE || @@ -1629,7 +1675,7 @@ export class Identity { this.#identityURI as WindowPostMessageOptions ); } else { - this.#handleIdentityResponse(ev.data); + await this.#handleIdentityResponse(ev.data); this.#identityPopupWindow?.close(); if (this.#boundPostMessageListener != null) { this.#window.removeEventListener( @@ -1644,10 +1690,10 @@ export class Identity { /** * @private */ - #handleIdentityResponse({ method, payload = {} }: IdentityResponse) { + async #handleIdentityResponse({ method, payload = {} }: IdentityResponse) { switch (method) { case 'derive': - this.#handleDeriveMethod(payload as IdentityDerivePayload) + await this.#handleDeriveMethod(payload as IdentityDerivePayload) .then(async (res) => { const state = await this.#getState(); this.#subscribers.forEach((s) => @@ -1710,10 +1756,12 @@ export class Identity { }); break; case 'login': - this.#handleLoginMethod(payload as IdentityLoginPayload).catch((e) => { - // propagate any error to the external caller - this.#pendingWindowRequest?.reject(this.#getErrorInstance(e)); - }); + await this.#handleLoginMethod(payload as IdentityLoginPayload).catch( + (e) => { + // propagate any error to the external caller + this.#pendingWindowRequest?.reject(this.#getErrorInstance(e)); + } + ); break; default: throw new Error(`Unknown method: ${method}`); @@ -2031,6 +2079,23 @@ export class Identity { undefined, `toolbar=no, width=${w}, height=${h}, top=${y}, left=${x}` ); + + const intervalId = setInterval(() => { + if (this.#identityPopupWindow?.closed) { + clearInterval(intervalId); + + // If the identity popup has been closed without having resolved the pending + // request, then we just reject it so the caller can handle it accordingly. + if (this.#pendingWindowRequest?.status === 'pending') { + this.#pendingWindowRequest.reject( + new DeSoCoreError( + 'Identity window was closed without any user interaction.', + ERROR_TYPES.IDENTITY_WINDOW_CLOSED + ) + ); + } + } + }, 300); } /** diff --git a/src/identity/permissions-utils.ts b/src/identity/permissions-utils.ts index 3f424fb..d1b72d7 100644 --- a/src/identity/permissions-utils.ts +++ b/src/identity/permissions-utils.ts @@ -94,6 +94,79 @@ export function compareTransactionSpendingLimits( : ['NFTOperationLimitMap', '', '0', path[path.length - 1]]; } break; + // TODO: support for making sure a derived key has these limits... + // @jacksondean - this is a little more annoying since + // stake and unstake limits don't have an op count, but rather a deso limit. + case 'StakeLimitMap': + if ( + actualPermissions?.StakeLimitMap?.find((map) => { + return ( + map.ValidatorPublicKeyBase58Check === '' && + expectedPermissions?.StakeLimitMap?.[Number(path[1])] + ?.StakeLimit && + parseInt(map.StakeLimit, 16) >= + parseInt( + expectedPermissions?.StakeLimitMap?.[Number(path[1])] + ?.StakeLimit, + 16 + ) + ); + }) + ) { + return; + } + break; + case 'UnstakeLimitMap': + if ( + actualPermissions?.UnstakeLimitMap?.find((map) => { + return ( + map.ValidatorPublicKeyBase58Check === '' && + expectedPermissions?.UnstakeLimitMap?.[Number(path[1])] + ?.UnstakeLimit && + parseInt(map.UnstakeLimit, 16) >= + parseInt( + expectedPermissions?.UnstakeLimitMap?.[Number(path[1])] + ?.UnstakeLimit, + 16 + ) + ); + }) + ) { + return; + } + break; + case 'UnlockStakeLimitMap': + if ( + actualPermissions?.UnlockStakeLimitMap?.find((map) => { + return ( + map.ValidatorPublicKeyBase58Check === '' && + map.OpCount >= + normalizeCount( + expectedPermissions?.UnlockStakeLimitMap?.[Number(path[1])] + ?.OpCount + ) + ); + }) + ) { + return; + } + break; + case 'LockupLimitMap': + if ( + actualPermissions?.LockupLimitMap?.find((map) => { + return ( + map.ProfilePublicKeyBase58Check === '' && + map.OpCount >= + normalizeCount( + expectedPermissions?.LockupLimitMap?.[Number(path[1])] + ?.OpCount + ) + ); + }) + ) { + return; + } + break; } const actualVal = getDeepValue(actualPermissions, path); @@ -136,9 +209,22 @@ export function buildTransactionSpendingLimitResponse( [] ); + if (result.StakeLimitMap) { + result.StakeLimitMap = Object.values(result.StakeLimitMap); + } + + if (result.UnstakeLimitMap) { + result.UnstakeLimitMap = Object.values(result.UnstakeLimitMap); + } + + if (result.UnlockStakeLimitMap) { + result.UnlockStakeLimitMap = Object.values(result.UnlockStakeLimitMap); + } + if (result.AccessGroupLimitMap) { result.AccessGroupLimitMap = Object.values(result.AccessGroupLimitMap); } + if (result.AccessGroupMemberLimitMap) { result.AccessGroupMemberLimitMap = Object.values( result.AccessGroupMemberLimitMap @@ -166,6 +252,7 @@ export function buildTransactionSpendingLimitResponse( } }); } + // TODO: support for new PoS Spending limits maps. result.TransactionCountLimitMap = result.TransactionCountLimitMap ?? {}; diff --git a/src/identity/transaction-transcoders.ts b/src/identity/transaction-transcoders.ts index 7141081..df41635 100644 --- a/src/identity/transaction-transcoders.ts +++ b/src/identity/transaction-transcoders.ts @@ -21,6 +21,9 @@ import { Uvarint64, VarBuffer, instanceToType, + VarBufferArray, + BoolOptional, + Varint64, } from './transcoders.js'; export class TransactionInput extends BinaryRecord { @Transcode(FixedBuffer(32)) @@ -599,6 +602,122 @@ export class TransactionMetadataNewMessage extends BinaryRecord { newMessageOperation = 0; } +export class TransactionMetadataRegisterAsValidator extends BinaryRecord { + @Transcode(VarBufferArray) + domains: Uint8Array[] = []; + + @Transcode(Boolean) + disableDelegatedStake = false; + + @Transcode(Uvarint64) + delegatedStakeCommissionBasisPoints = 0; + + // TODO: Technically this is a bls public key, + // but under the hood it's really just a byte array. + // The challenge is converting this into something human + // readable in the UI. + @Transcode(VarBuffer) + votingPublicKey: Uint8Array = new Uint8Array(0); + + // TODO: Technically this is a bls signature, + // but under the hood it's really just a byte array. + // The challenge is converting this into something human + // readable in the UI. + @Transcode(VarBuffer) + votingAuthorization: Uint8Array = new Uint8Array(0); +} + +export class TransactionMetadataUnregisterAsValidator extends BinaryRecord {} + +export class TransactionMetadataStake extends BinaryRecord { + @Transcode(VarBuffer) + validatorPublicKey: Uint8Array = new Uint8Array(0); + + @Transcode(Uint8) + rewardMethod = 0; + + // TODO: We may want a better way to handle uint256s. + @Transcode(BoolOptional(VarBuffer)) + stakeAmountNanos: Uint8Array = new Uint8Array(0); +} + +export class TransactionMetadataUnstake extends BinaryRecord { + @Transcode(VarBuffer) + validatorPublicKey: Uint8Array = new Uint8Array(0); + + // TODO: We may want a better way to handle uint256s. + @Transcode(BoolOptional(VarBuffer)) + unstakeAmountNanos: Uint8Array = new Uint8Array(0); +} + +export class TransactionMetadataUnlockStake extends BinaryRecord { + @Transcode(VarBuffer) + validatorPublicKey: Uint8Array = new Uint8Array(0); + + @Transcode(Uvarint64) + startEpochNumber = 0; + + @Transcode(Uvarint64) + endEpochNumber = 0; +} + +export class TransactionMetadataUnjailValidator extends BinaryRecord {} + +export class TransactionMetadataCoinLockup extends BinaryRecord { + @Transcode(VarBuffer) + profilePublicKey: Uint8Array = new Uint8Array(0); + + @Transcode(VarBuffer) + recipientPublicKey: Uint8Array = new Uint8Array(0); + + @Transcode(Varint64) + unlockTimestampNanoSecs = 0; + + @Transcode(Varint64) + vestingEndTimestampNanoSecs = 0; + + // TODO: We may want a better way to handle uint256s. + @Transcode(BoolOptional(VarBuffer)) + lockupAmountBaseUnits: Uint8Array = new Uint8Array(0); +} + +export class TransactionMetadataUpdateCoinLockupParams extends BinaryRecord { + @Transcode(Varint64) + lockupYieldDurationNanoSecs = 0; + + @Transcode(Uvarint64) + lockupYieldAPYBasisPoints = 0; + + @Transcode(Boolean) + removeYieldCurvePoint = false; + + @Transcode(Boolean) + newLockupTransferRestrictions = false; + + @Transcode(Uint8) + lockupTransferRestrictionStatus = 0; +} + +export class TransactionMetadataCoinLockupTransfer extends BinaryRecord { + @Transcode(VarBuffer) + recipientPublicKey: Uint8Array = new Uint8Array(0); + + @Transcode(VarBuffer) + profilePublicKey: Uint8Array = new Uint8Array(0); + + @Transcode(Varint64) + unlockTimestampNanoSecs = 0; + + // TODO: We may want a better way to handle uint256s. + @Transcode(BoolOptional(VarBuffer)) + lockedCoinsToTransferBaseUnits: Uint8Array = new Uint8Array(0); +} + +export class TransactionMetadataCoinUnlock extends BinaryRecord { + @Transcode(VarBuffer) + profilePublicKey: Uint8Array = new Uint8Array(0); +} + export const TransactionTypeMetadataMap = { 1: TransactionMetadataBlockReward, 2: TransactionMetadataBasicTransfer, @@ -632,6 +751,12 @@ export const TransactionTypeMetadataMap = { 31: TransactionMetadataAccessGroup, 32: TransactionMetadataAccessGroupMembers, 33: TransactionMetadataNewMessage, + 34: TransactionMetadataRegisterAsValidator, + 35: TransactionMetadataUnregisterAsValidator, + 36: TransactionMetadataStake, + 37: TransactionMetadataUnstake, + 38: TransactionMetadataUnlockStake, + 39: TransactionMetadataUnjailValidator, }; export const TransactionTypeToStringMap: { [k: number]: string } = { @@ -668,6 +793,16 @@ export const TransactionTypeToStringMap: { [k: number]: string } = { 31: TransactionType.AccessGroup, 32: TransactionType.AccessGroupMembers, 33: TransactionType.NewMessage, + 34: TransactionType.RegisterAsValidator, + 35: TransactionType.UnregisterAsValidator, + 36: TransactionType.Stake, + 37: TransactionType.Unstake, + 38: TransactionType.UnlockStake, + 39: TransactionType.UnjailValidator, + 40: TransactionType.CoinLockup, + 41: TransactionType.UpdateCoinLockupParams, + 42: TransactionType.CoinLockupTransfer, + 43: TransactionType.CoinUnlock, }; export class Transaction extends BinaryRecord { diff --git a/src/identity/transcoders.ts b/src/identity/transcoders.ts index 032aa5f..091e038 100644 --- a/src/identity/transcoders.ts +++ b/src/identity/transcoders.ts @@ -1,8 +1,10 @@ import 'reflect-metadata'; import { bufToUvarint64, + bufToVarint64, concatUint8Arrays, uvarint64ToBuf, + varint64ToBuf, } from './crypto-utils.js'; import { TransactionNonce } from './transaction-transcoders.js'; export class BinaryRecord { @@ -69,6 +71,11 @@ export const Uvarint64: Transcoder = { write: (uint) => uvarint64ToBuf(uint), }; +export const Varint64: Transcoder = { + read: (bytes) => bufToVarint64(bytes), + write: (int) => varint64ToBuf(int), +}; + export const Boolean: Transcoder = { read: (bytes) => [bytes.at(0) != 0, bytes.slice(1)], write: (bool) => { @@ -95,6 +102,33 @@ export const VarBuffer: Transcoder = { }, write: (bytes) => concatUint8Arrays([uvarint64ToBuf(bytes.length), bytes]), }; + +export const VarBufferArray: Transcoder = { + read: (bytes) => { + const countAndBuffer = bufToUvarint64(bytes); + const count = countAndBuffer[0]; + let buffer = countAndBuffer[1]; + const result = []; + for (let i = 0; i < count; i++) { + let size; + [size, buffer] = bufToUvarint64(buffer); + result.push(buffer.slice(0, size)); + buffer = buffer.slice(size); + } + + return [result, buffer]; + }, + write: (buffers) => { + const count = uvarint64ToBuf(buffers.length); + return concatUint8Arrays([ + count, + ...buffers.map((buffer) => + concatUint8Arrays([uvarint64ToBuf(buffer.length), buffer]) + ), + ]); + }, +}; + export const TransactionNonceTranscoder: Transcoder = { read: (bytes) => { return TransactionNonce.fromBytes(bytes) as [TransactionNonce, Uint8Array]; @@ -116,6 +150,26 @@ export function Optional(transcoder: Transcoder): Transcoder { }; } +export function BoolOptional( + transcoder: Transcoder +): Transcoder { + return { + read: (bytes: Uint8Array) => { + const existence = bytes.at(0) != 0; + if (!existence) { + return [null, bytes.slice(1)]; + } + return transcoder.read(bytes.slice(1)); + }, + write: (value: T | null) => { + if (value === null) { + return Uint8Array.from([0]); + } + return concatUint8Arrays([Uint8Array.from([1]), transcoder.write(value)]); + }, + }; +} + export const ChunkBuffer = (width: number): Transcoder => ({ read: (bytes) => { const countAndBuffer = bufToUvarint64(bytes); diff --git a/src/identity/types.ts b/src/identity/types.ts index 1149ef1..75c15de 100644 --- a/src/identity/types.ts +++ b/src/identity/types.ts @@ -2,8 +2,12 @@ import { AccessGroupLimitMapItem, AccessGroupMemberLimitMapItem, AssociationLimitMapItem, + LockupLimitMapItem, + StakeLimitMapItem, TransactionSpendingLimitResponse, TransactionType, + UnlockStakeLimitMapItem, + UnstakeLimitMapItem, } from '../backend-types/index.js'; export type Network = 'mainnet' | 'testnet'; @@ -61,6 +65,18 @@ export interface TransactionSpendingLimitResponseOptions { AccessGroupMemberLimitMapItem, 'OpCount' > & { OpCount: number | 'UNLIMITED' })[]; + StakeLimitMap?: (Omit & { + StakeLimit: string | 'UNLIMITED'; // TODO: handle unlimited for DESO limit. + })[]; + UnstakeLimitMap?: (Omit & { + UnstakeLimit: string | 'UNLIMITED'; // TODO: handle unlimited for DESO limit. + })[]; + UnlockStakeLimitMap?: (Omit & { + OpCount: number | 'UNLIMITED'; + })[]; + LockupLimitMap?: (Omit & { + OpCount: number | 'UNLIMITED'; + })[]; IsUnlimited?: boolean; } @@ -209,11 +225,6 @@ export interface IdentityState { alternateUsers: Record | null; } -export interface Deferred { - resolve: (args: any) => void; - reject: (args: any) => void; -} - export interface KeyPair { seedHex: string; private: Uint8Array; diff --git a/src/index.ts b/src/index.ts index 7799033..fc02efc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,6 @@ export * from './transactions/deso-tokens.js'; export * from './transactions/financial.js'; export * from './transactions/nfts.js'; export * from './transactions/social.js'; +export * from './transactions/validator.js'; +export * from './transactions/stake.js'; +export * from './transactions/lockup.js'; diff --git a/src/transactions/lockup.ts b/src/transactions/lockup.ts new file mode 100644 index 0000000..36efdf4 --- /dev/null +++ b/src/transactions/lockup.ts @@ -0,0 +1,335 @@ +import { + ConstructedAndSubmittedTx, + TxRequestOptions, + TypeWithOptionalFeesAndExtraData, +} from '../types.js'; +import { + CoinLockResponse, + CoinLockupRequest, + CoinLockupTransferRequest, + CoinUnlockRequest, + ConstructedTransactionResponse, + LockupLimitMapItem, + LockupLimitOperationString, + LockupLimitScopeType, + UpdateCoinLockupParamsRequest, +} from '../backend-types/index.js'; +import { + bs58PublicKeyToCompressedBytes, + TransactionMetadataCoinLockup, + TransactionMetadataCoinLockupTransfer, + TransactionMetadataCoinUnlock, + TransactionMetadataUpdateCoinLockupParams, +} from '../identity/index.js'; +import { hexToBytes } from '@noble/hashes/utils'; +import { + constructBalanceModelTx, + getTxWithFeeNanos, + handleSignAndSubmit, + sumTransactionFees, +} from '../internal.js'; +import { guardTxPermission } from './utils.js'; + +type CoinLockupRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildCoinLockupMetadata = (params: CoinLockupRequestParams) => { + const metadata = new TransactionMetadataCoinLockup(); + metadata.profilePublicKey = bs58PublicKeyToCompressedBytes( + params.ProfilePublicKeyBase58Check + ); + metadata.recipientPublicKey = bs58PublicKeyToCompressedBytes( + params.RecipientPublicKeyBase58Check + ); + // TODO: make sure this replace is correct. + metadata.lockupAmountBaseUnits = hexToBytes( + params.LockupAmountBaseUnits.replace('0x', 'x') + ); + metadata.unlockTimestampNanoSecs = params.UnlockTimestampNanoSecs; + metadata.vestingEndTimestampNanoSecs = params.VestingEndTimestampNanoSecs; + return metadata; +}; + +export const constructCoinLockupTransaction = ( + params: CoinLockupRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildCoinLockupMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const coinLockup = async ( + params: CoinLockupRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildCoinLockupMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + LockupLimitMap: [ + { + ProfilePublicKeyBase58Check: params.ProfilePublicKeyBase58Check, + Operation: LockupLimitOperationString.COIN_LOCKUP, + ScopeType: LockupLimitScopeType.SCOPED, + OpCount: options?.txLimitCount ?? 1, + }, + ], + }); + } + + return handleSignAndSubmit('api/v0/coin-lockup', params, { + ...options, + constructionFunction: constructCoinLockupTransaction, + }); +}; + +type CoinUnlockRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildCoinUnlockMetadata = (params: CoinUnlockRequestParams) => { + const metadata = new TransactionMetadataCoinUnlock(); + metadata.profilePublicKey = bs58PublicKeyToCompressedBytes( + params.ProfilePublicKeyBase58Check + ); + return metadata; +}; + +export const constructCoinUnlockTransaction = ( + params: CoinUnlockRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildCoinUnlockMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const coinUnlock = async ( + params: CoinUnlockRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildCoinUnlockMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + LockupLimitMap: [ + { + ProfilePublicKeyBase58Check: params.ProfilePublicKeyBase58Check, + Operation: LockupLimitOperationString.COIN_UNLOCK, + ScopeType: LockupLimitScopeType.SCOPED, + OpCount: options?.txLimitCount ?? 1, + }, + ], + }); + } + + return handleSignAndSubmit('api/v0/coin-unlock', params, { + ...options, + constructionFunction: constructCoinUnlockTransaction, + }); +}; + +type CoinLockupTransferRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildCoinLockupTransferMetadata = ( + params: CoinLockupTransferRequestParams +) => { + const metadata = new TransactionMetadataCoinLockupTransfer(); + metadata.profilePublicKey = bs58PublicKeyToCompressedBytes( + params.ProfilePublicKeyBase58Check + ); + metadata.recipientPublicKey = bs58PublicKeyToCompressedBytes( + params.RecipientPublicKeyBase58Check + ); + metadata.unlockTimestampNanoSecs = params.UnlockTimestampNanoSecs; + // TODO: make sure this replace is correct. + metadata.lockedCoinsToTransferBaseUnits = hexToBytes( + params.LockedCoinsToTransferBaseUnits.replace('0x', 'x') + ); + return metadata; +}; + +export const constructCoinLockupTransferTransaction = ( + params: CoinLockupTransferRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildCoinLockupTransferMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const coinLockupTransfer = async ( + params: CoinLockupTransferRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildCoinLockupTransferMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + LockupLimitMap: [ + { + ProfilePublicKeyBase58Check: params.ProfilePublicKeyBase58Check, + Operation: LockupLimitOperationString.COIN_LOCKUP_TRANSFER, + ScopeType: LockupLimitScopeType.SCOPED, + OpCount: options?.txLimitCount ?? 1, + }, + ], + }); + } + + return handleSignAndSubmit('api/v0/coin-lockup-transfer', params, { + ...options, + constructionFunction: constructCoinLockupTransferTransaction, + }); +}; + +type UpdateCoinLockupParamsRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildUpdateCoinLockupParamsMetadata = ( + params: UpdateCoinLockupParamsRequestParams +) => { + const metadata = new TransactionMetadataUpdateCoinLockupParams(); + metadata.lockupYieldDurationNanoSecs = params.LockupYieldDurationNanoSecs; + metadata.lockupYieldAPYBasisPoints = params.LockupYieldAPYBasisPoints; + metadata.removeYieldCurvePoint = params.RemoveYieldCurvePoint; + metadata.newLockupTransferRestrictions = params.NewLockupTransferRestrictions; + let transferRestrictionStatus: number; + switch (params.LockupTransferRestrictionStatus) { + case 'dao_members_only': + transferRestrictionStatus = 2; + break; + case 'permanently_unrestricted': + transferRestrictionStatus = 3; + break; + case 'profile_owner_only': + transferRestrictionStatus = 1; + break; + case 'unrestricted': + transferRestrictionStatus = 0; + break; + default: + throw new Error('Invalid LockupTransferRestrictionStatus'); + } + metadata.lockupTransferRestrictionStatus = transferRestrictionStatus; + return metadata; +}; + +export const constructUpdateCoinLockupParamsTransaction = ( + params: UpdateCoinLockupParamsRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildUpdateCoinLockupParamsMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const updateCoinLockupParams = async ( + params: UpdateCoinLockupParamsRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildUpdateCoinLockupParamsMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + // TODO: this one is tricky since a single transaction can reduce from two + // different limits. + // @jacksondean - help me plzzz. + const newLockupTransferRestrictionLimit = + params.NewLockupTransferRestrictions + ? { + ProfilePublicKeyBase58Check: params.TransactorPublicKeyBase58Check, + Operation: + LockupLimitOperationString.UPDATE_COIN_LOCKUP_TRANSFER_RESTRICTIONS, + ScopeType: LockupLimitScopeType.SCOPED, + OpCount: options?.txLimitCount ?? 1, + } + : null; + const addYieldCurvePointLimit = { + ProfilePublicKeyBase58Check: params.TransactorPublicKeyBase58Check, + Operation: LockupLimitOperationString.UPDATE_COIN_LOCKUP_YIELD_CURVE, + ScopeType: LockupLimitScopeType.SCOPED, + OpCount: options?.txLimitCount ?? 1, + }; + const limits = [addYieldCurvePointLimit]; + if (newLockupTransferRestrictionLimit) { + limits.push(newLockupTransferRestrictionLimit); + } + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + LockupLimitMap: limits, + }); + } + + return handleSignAndSubmit('api/v0/update-coin-lockup-params', params, { + ...options, + constructionFunction: constructUpdateCoinLockupParamsTransaction, + }); +}; diff --git a/src/transactions/stake.ts b/src/transactions/stake.ts new file mode 100644 index 0000000..35db488 --- /dev/null +++ b/src/transactions/stake.ts @@ -0,0 +1,232 @@ +import { hexToBytes } from '@noble/hashes/utils'; +import { + ConstructedTransactionResponse, + StakeRequest, + StakeRewardMethod, + StakeTxnResponse, + UnlockStakeRequest, + UnstakeRequest, +} from '../backend-types/index.js'; +import { + TransactionMetadataStake, + TransactionMetadataUnlockStake, + TransactionMetadataUnstake, + bs58PublicKeyToCompressedBytes, + concatUint8Arrays, +} from '../identity/index.js'; +import { + constructBalanceModelTx, + getTxWithFeeNanos, + handleSignAndSubmit, + sumTransactionFees, +} from '../internal.js'; +import { + ConstructedAndSubmittedTx, + TxRequestOptions, + TypeWithOptionalFeesAndExtraData, +} from '../types.js'; +import { guardTxPermission, stripHexPrefix } from './utils.js'; + +type StakeRequestParams = TypeWithOptionalFeesAndExtraData; + +const buildStakeMetadata = (params: StakeRequestParams) => { + const metadata = new TransactionMetadataStake(); + metadata.validatorPublicKey = bs58PublicKeyToCompressedBytes( + params.ValidatorPublicKeyBase58Check + ); + metadata.rewardMethod = + params.RewardMethod === StakeRewardMethod.PayToBalance ? 0 : 1; + metadata.stakeAmountNanos = hexToBytes( + stripHexPrefix(params.StakeAmountNanos) + ); + + return metadata; +}; + +export const constructStakeTransaction = ( + params: StakeRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildStakeMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const stake = async ( + params: StakeRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildStakeMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + // NOTE: there must be a non-zero stake limit in order for the transaction to + // get accepted. If a user is only trying to update their reward method then + // it is possible for the stake limit to actually be 0. In this case, we set + // the stake limit to a very tiny amount of 1 nano. + const stakeLimit = + parseInt(params.StakeAmountNanos, 16) === 0 + ? '0x1' + : params.StakeAmountNanos; + + const GlobalDESOLimit = + parseInt(stakeLimit, 16) + + txWithFee.feeNanos + + sumTransactionFees(params.TransactionFees); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit, + StakeLimitMap: [ + { + ValidatorPublicKeyBase58Check: params.ValidatorPublicKeyBase58Check, + StakeLimit: stakeLimit, + }, + ], + }); + } + + return handleSignAndSubmit('api/v0/stake', params, { + ...options, + constructionFunction: constructStakeTransaction, + }); +}; + +type UnstakeRequestParams = TypeWithOptionalFeesAndExtraData; + +const buildUnstakeMetadata = (params: UnstakeRequestParams) => { + const metadata = new TransactionMetadataUnstake(); + metadata.validatorPublicKey = bs58PublicKeyToCompressedBytes( + params.ValidatorPublicKeyBase58Check + ); + const hex = stripHexPrefix(params.UnstakeAmountNanos); + metadata.unstakeAmountNanos = + hex === '0' ? new Uint8Array([0]) : hexToBytes(hex); + + return metadata; +}; + +export const constructUnstakeTransaction = ( + params: UnstakeRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildUnstakeMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const unstake = async ( + params: UnstakeRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildUnstakeMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + UnstakeLimitMap: [ + { + ValidatorPublicKeyBase58Check: params.ValidatorPublicKeyBase58Check, + UnstakeLimit: params.UnstakeAmountNanos, + }, + ], + }); + } + + return handleSignAndSubmit('api/v0/unstake', params, { + ...options, + constructionFunction: constructUnstakeTransaction, + }); +}; + +type UnlockStakeRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildUnlockStakeMetadata = (params: UnlockStakeRequestParams) => { + const metadata = new TransactionMetadataUnlockStake(); + metadata.validatorPublicKey = bs58PublicKeyToCompressedBytes( + params.ValidatorPublicKeyBase58Check + ); + metadata.startEpochNumber = params.StartEpochNumber; + metadata.endEpochNumber = params.EndEpochNumber; + + return metadata; +}; + +export const constructUnlockStakeTransaction = ( + params: UnlockStakeRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildUnlockStakeMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const unlockStake = async ( + params: UnlockStakeRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildUnlockStakeMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + UnlockStakeLimitMap: [ + { + ValidatorPublicKeyBase58Check: params.ValidatorPublicKeyBase58Check, + OpCount: options?.txLimitCount ?? 1, + }, + ], + }); + } + + return handleSignAndSubmit('api/v0/unlock-stake', params, { + ...options, + constructionFunction: constructUnlockStakeTransaction, + }); +}; diff --git a/src/transactions/utils.ts b/src/transactions/utils.ts index 40cb319..fcbe77a 100644 --- a/src/transactions/utils.ts +++ b/src/transactions/utils.ts @@ -24,3 +24,13 @@ export async function guardTxPermission( return (hasPermissions as Promise).then(guard); } } + +export function stripHexPrefix(hex: string) { + const unPadded = hex.startsWith('0x') ? hex.slice(2) : hex; + + if (unPadded.length % 2 === 1) { + return `0${unPadded}`; + } + + return unPadded; +} diff --git a/src/transactions/validator.ts b/src/transactions/validator.ts new file mode 100644 index 0000000..6c57a9a --- /dev/null +++ b/src/transactions/validator.ts @@ -0,0 +1,218 @@ +import { hexToBytes } from '@noble/hashes/utils'; +import { + ConstructedTransactionResponse, + RegisterAsValidatorRequest, + UnjailValidatorRequest, + UnregisterAsValidatorRequest, + ValidatorTxnResponse, +} from '../backend-types/index.js'; +import { + TransactionMetadataRegisterAsValidator, + TransactionMetadataUnjailValidator, + TransactionMetadataUnregisterAsValidator, + encodeUTF8ToBytes, + identity, +} from '../identity/index.js'; +import { + constructBalanceModelTx, + getTxWithFeeNanos, + handleSignAndSubmit, + sumTransactionFees, +} from '../internal.js'; +import { + ConstructedAndSubmittedTx, + TxRequestOptions, + TypeWithOptionalFeesAndExtraData, +} from '../types.js'; +import { guardTxPermission, stripHexPrefix } from './utils.js'; + +type RegisterAsValidatorRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildRegisterAsValidatorMetadata = ( + params: RegisterAsValidatorRequestParams +) => { + const metadata = new TransactionMetadataRegisterAsValidator(); + metadata.domains = params.Domains.map((d) => encodeUTF8ToBytes(d)); + metadata.delegatedStakeCommissionBasisPoints = + params.DelegatedStakeCommissionBasisPoints; + metadata.disableDelegatedStake = params.DisableDelegatedStake; + metadata.votingPublicKey = hexToBytes(stripHexPrefix(params.VotingPublicKey)); + metadata.votingAuthorization = hexToBytes( + stripHexPrefix(params.VotingAuthorization) + ); + + return metadata; +}; + +export const constructRegisterAsValidatorTransaction = ( + params: RegisterAsValidatorRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildRegisterAsValidatorMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const registerAsValidator = async ( + params: RegisterAsValidatorRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx< + ValidatorTxnResponse | ConstructedTransactionResponse + > +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildRegisterAsValidatorMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + TransactionCountLimitMap: { + REGISTER_AS_VALIDATOR: + options?.txLimitCount ?? + identity.transactionSpendingLimitOptions?.TransactionCountLimitMap + ?.REGISTER_AS_VALIDATOR ?? + 1, + }, + }); + } + + return handleSignAndSubmit('api/v0/validators/register', params, { + ...options, + constructionFunction: constructRegisterAsValidatorTransaction, + }); +}; + +type UnregisterAsValidatorRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildUnregisterAsValidatorMetadata = ( + params: UnregisterAsValidatorRequestParams +) => { + return new TransactionMetadataUnregisterAsValidator(); +}; + +export const constructUnregisterAsValidatorTransaction = ( + params: UnregisterAsValidatorRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildUnregisterAsValidatorMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const unRegisterAsValidator = async ( + params: UnregisterAsValidatorRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx< + ValidatorTxnResponse | ConstructedTransactionResponse + > +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildUnregisterAsValidatorMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + TransactionCountLimitMap: { + UNREGISTER_AS_VALIDATOR: + options?.txLimitCount ?? + identity.transactionSpendingLimitOptions?.TransactionCountLimitMap + ?.UNREGISTER_AS_VALIDATOR ?? + 1, + }, + }); + } + + return handleSignAndSubmit('api/v0/validators/unregister', params, { + ...options, + constructionFunction: constructUnregisterAsValidatorTransaction, + }); +}; + +type UnjailValidatorRequestParams = + TypeWithOptionalFeesAndExtraData; + +const buildUnjailValidatorMetadata = (params: UnjailValidatorRequestParams) => { + return new TransactionMetadataUnjailValidator(); +}; + +export const constructUnjailValidatorTransaction = ( + params: UnjailValidatorRequestParams +): Promise => { + return constructBalanceModelTx( + params.TransactorPublicKeyBase58Check, + buildUnjailValidatorMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); +}; + +export const unJailValidator = async ( + params: UnjailValidatorRequestParams, + options?: TxRequestOptions +): Promise< + ConstructedAndSubmittedTx< + ValidatorTxnResponse | ConstructedTransactionResponse + > +> => { + const txWithFee = getTxWithFeeNanos( + params.TransactorPublicKeyBase58Check, + buildUnjailValidatorMetadata(params), + { + ExtraData: params.ExtraData, + MinFeeRateNanosPerKB: params.MinFeeRateNanosPerKB, + TransactionFees: params.TransactionFees, + } + ); + + if (options?.checkPermissions !== false) { + await guardTxPermission({ + GlobalDESOLimit: + txWithFee.feeNanos + sumTransactionFees(params.TransactionFees), + TransactionCountLimitMap: { + UNJAIL_VALIDATOR: + options?.txLimitCount ?? + identity.transactionSpendingLimitOptions?.TransactionCountLimitMap + ?.UNREGISTER_AS_VALIDATOR ?? + 1, + }, + }); + } + + return handleSignAndSubmit('api/v0/validators/unjail', params, { + ...options, + constructionFunction: constructUnjailValidatorTransaction, + }); +};