Skip to content

Commit

Permalink
fix: lock authorities and token authorities can unlock fungibles
Browse files Browse the repository at this point in the history
Port changes from
GalaChain#455 to v2

Closes GalaChain#446

When defined, a lockAuthority on a TokenHold can unlock the token
regardless of ownership.

A token authority can unlock a TokenHold on another users balance
regardless of lockAuthority status.

Refactoring or reworking order of unlocks by token authorities
is out of scope for this immediate fix, should be pursued in a
separate issue if needed.
  • Loading branch information
sentientforest committed Dec 12, 2024
1 parent 143da54 commit 37529b2
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 17 deletions.
89 changes: 86 additions & 3 deletions chain-api/src/types/TokenBalance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import BigNumber from "bignumber.js";
import { TokenBalance, TokenHold } from "./TokenBalance";
import { TokenClassKey } from "./TokenClass";
import { TokenInstance } from "./TokenInstance";
import { UserAlias } from "./UserAlias";
import { UserAlias, asValidUserAlias } from "./UserAlias";

/*
* Copyright (c) Gala Games Inc. All rights reserved.
Expand All @@ -30,14 +30,21 @@ function emptyBalance() {
});
}

function createHold(instance: BigNumber, expires: number, quantity?: BigNumber, name?: string) {
function createHold(
instance: BigNumber,
expires: number,
quantity?: BigNumber,
name?: string,
lockAuthority?: string
) {
return new TokenHold({
createdBy: "client|user1" as UserAlias,
instanceId: instance,
quantity: quantity ?? new BigNumber(1),
created: 1,
expires: expires,
name: name
name: name,
lockAuthority: lockAuthority ? asValidUserAlias(lockAuthority) : undefined
});
}

Expand Down Expand Up @@ -288,6 +295,82 @@ describe("fungible", () => {
expect(remainingLockedQuantity.toNumber()).toEqual(expectedQuantityToRemainLocked.toNumber());
expect(unexpiredLockedHolds.length).toBe(expectedHoldsRemainingOnBalance.toNumber());
});

it("should permit identity defined as a lockAuthority to unlock", () => {
// Given
const balance = emptyBalance();
const testQuantity = new BigNumber(10);
const lockAuthority = "client|admin";
const hold = createHold(TokenInstance.FUNGIBLE_TOKEN_INSTANCE, 0, testQuantity, undefined, lockAuthority);

balance.addQuantity(testQuantity);
balance.lockQuantity(hold);

// When
const quantityLockedBeforeUnlock = balance.getLockedQuantityTotal(Date.now());

balance.unlockQuantity(testQuantity, Date.now(), undefined, lockAuthority);

const quantityLockedAfterUnlock = balance.getLockedQuantityTotal(Date.now());

// Then
expect(quantityLockedBeforeUnlock).toEqual(new BigNumber(10));
expect(quantityLockedAfterUnlock).toEqual(new BigNumber(0));
expect(balance.owner).not.toEqual(lockAuthority);
});

it("should prevent owner from unlocking if a lockAuthority is defined", () => {
// Given
const balance = emptyBalance();
const hold = createHold(
TokenInstance.FUNGIBLE_TOKEN_INSTANCE,
0,
new BigNumber(10),
undefined,
"client|admin"
);

balance.addQuantity(new BigNumber(10));
balance.lockQuantity(hold);

// When
const error = () => balance.unlockQuantity(new BigNumber(10), Date.now(), undefined, balance.owner);

const tokenClassKey = TokenClassKey.toStringKey({ ...balance });

// Then
expect(error).toThrow(
`Failed to unlock quantity 10 of Fungible token ${tokenClassKey} ` + `for TokenHold.name = undefined.`
);
});

it("should let a token authority unlock regardless of lockAuthority definition", () => {
// Given
const balance = emptyBalance();
const testQuantity = new BigNumber(10);
const hold = createHold(
TokenInstance.FUNGIBLE_TOKEN_INSTANCE,
0,
testQuantity,
undefined,
"client|admin"
);

balance.addQuantity(testQuantity);
balance.lockQuantity(hold);

const isTokenAuthority = true;
// When
const quantityLockedBeforeUnlock = balance.getLockedQuantityTotal(Date.now());

balance.unlockQuantity(testQuantity, Date.now(), undefined, undefined, isTokenAuthority);

const quantityLockedAfterUnlock = balance.getLockedQuantityTotal(Date.now());

// Then
expect(quantityLockedBeforeUnlock).toEqual(new BigNumber(10));
expect(quantityLockedAfterUnlock).toEqual(new BigNumber(0));
});
});

describe("non-fungible", () => {
Expand Down
20 changes: 14 additions & 6 deletions chain-api/src/types/TokenBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,19 +425,27 @@ export class TokenBalance extends ChainObject {
this.lockedHolds = [...this.getUnexpiredLockedHolds(hold.created), hold];
}

private isMatchingHold(hold: TokenHold, name?: string, lockAuthority?: string): boolean {
private isCallingUserAuthorized(
hold: TokenHold,
name?: string,
callingUser?: string,
isTokenAuthority?: boolean
): boolean {
return (
(hold.name === name || (hold.name === undefined && name === undefined)) &&
(hold.lockAuthority === lockAuthority ||
(hold.lockAuthority === undefined && lockAuthority === undefined))
hold.name === name &&
(isTokenAuthority ||
callingUser === hold.lockAuthority ||
(hold.lockAuthority === undefined && callingUser === this.owner) ||
(hold.lockAuthority === undefined && callingUser === hold.createdBy))
);
}

public unlockQuantity(
quantity: BigNumber,
currentTime: number,
name?: string,
lockAuthority?: string
callingUser?: string,
isTokenAuthority?: boolean
): void {
const unexpiredLockedHolds = this.getUnexpiredLockedHoldsSortedByAscendingExpiration(currentTime);

Expand All @@ -446,7 +454,7 @@ export class TokenBalance extends ChainObject {

for (const hold of unexpiredLockedHolds) {
// if neither the authority nor the name match, just leave this hold alone
if (!this.isMatchingHold(hold, name, lockAuthority)) {
if (!this.isCallingUserAuthorized(hold, name, callingUser, isTokenAuthority)) {
updated.push(hold);
continue;
}
Expand Down
9 changes: 1 addition & 8 deletions chaincode/src/locks/unlockToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,12 @@ export async function unlockFungibleToken(

// determine if user is authorized to unlock
// if calling user is not authorized, always token class authority can unlock
let lockAuthority: string = ctx.callingUser;
const tokenClass = await fetchTokenClass(ctx, tokenInstanceKey);
const isTokenAuthority = tokenClass.authorities.includes(ctx.callingUser);

if (!isTokenAuthority && ctx.callingUser !== owner) {
throw new UnlockForbiddenUserError(ctx.callingUser, tokenInstanceKey.toStringKey());
} else if (isTokenAuthority) {
lockAuthority = owner;
}

const balance = await fetchOrCreateBalance(ctx, owner, tokenInstanceKey.getTokenClassKey());

balance.unlockQuantity(quantityToUnlock, ctx.txUnixTime, name, lockAuthority);
balance.unlockQuantity(quantityToUnlock, ctx.txUnixTime, name, ctx.callingUser, isTokenAuthority);

await putChainObject(ctx, balance);

Expand Down

0 comments on commit 37529b2

Please sign in to comment.