From 043620b637d20348e56df11e7d3086facf72ea14 Mon Sep 17 00:00:00 2001 From: tequ Date: Fri, 3 Oct 2025 01:50:42 +0900 Subject: [PATCH] feat: add Clawback amendment support (#2353) (#28) * Add Clawback transaction * Account flag lsfAllowTrustLineClawback * Support bitwise flag checking of 64 bit flags Co-authored-by: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> --- .ci-config/xahaud.cfg | 1 + .../src/enums/definitions.json | 1 + .../xahau/src/models/ledger/AccountRoot.ts | 9 ++ .../src/models/transactions/accountSet.ts | 2 + .../xahau/src/models/transactions/clawback.ts | 49 ++++++++ .../xahau/src/models/transactions/index.ts | 1 + .../src/models/transactions/transaction.ts | 6 + .../integration/transactions/clawback.test.ts | 115 ++++++++++++++++++ packages/xahau/test/models/clawback.test.ts | 81 ++++++++++++ packages/xahau/test/models/utils.test.ts | 7 +- 10 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 packages/xahau/src/models/transactions/clawback.ts create mode 100644 packages/xahau/test/integration/transactions/clawback.test.ts create mode 100644 packages/xahau/test/models/clawback.test.ts diff --git a/.ci-config/xahaud.cfg b/.ci-config/xahaud.cfg index 91bbf9f7..bc62973a 100644 --- a/.ci-config/xahaud.cfg +++ b/.ci-config/xahaud.cfg @@ -167,6 +167,7 @@ fixXahauV1 fixXahauV2 fixXahauV3 PaychanAndEscrowForTokens +Clawback [network_id] 21337 diff --git a/packages/xahau-binary-codec/src/enums/definitions.json b/packages/xahau-binary-codec/src/enums/definitions.json index 2c88bfad..449b81e8 100644 --- a/packages/xahau-binary-codec/src/enums/definitions.json +++ b/packages/xahau-binary-codec/src/enums/definitions.json @@ -2829,6 +2829,7 @@ "NFTokenCreateOffer": 27, "NFTokenCancelOffer": 28, "NFTokenAcceptOffer": 29, + "Clawback": 30, "URITokenMint": 45, "URITokenBurn": 46, "URITokenBuy": 47, diff --git a/packages/xahau/src/models/ledger/AccountRoot.ts b/packages/xahau/src/models/ledger/AccountRoot.ts index ce3f61ee..acca80b0 100644 --- a/packages/xahau/src/models/ledger/AccountRoot.ts +++ b/packages/xahau/src/models/ledger/AccountRoot.ts @@ -152,6 +152,11 @@ export interface AccountRootFlagsInterface { * Disallow incoming Remit from other accounts. */ lsfDisallowIncomingRemit?: boolean + + /** + * This address can claw back issued IOUs. Once enabled, cannot be disabled. + */ + lsfAllowTrustLineClawback?: boolean } export enum AccountRootFlags { @@ -216,4 +221,8 @@ export enum AccountRootFlags { * Disallow incoming Remits from other accounts. */ lsfDisallowIncomingRemit = 0x80000000, + /** + * This address can claw back issued IOUs. Once enabled, cannot be disabled. + */ + lsfAllowTrustLineClawback = 0x00001000, } diff --git a/packages/xahau/src/models/transactions/accountSet.ts b/packages/xahau/src/models/transactions/accountSet.ts index dbd789b9..dde9b552 100644 --- a/packages/xahau/src/models/transactions/accountSet.ts +++ b/packages/xahau/src/models/transactions/accountSet.ts @@ -61,6 +61,8 @@ export enum AccountSetAsfFlags { asfDisallowIncomingTrustline = 15, /** Disallow other accounts from sending incoming Remits */ asfDisallowIncomingRemit = 16, + /** Permanently gain the ability to claw back issued IOUs */ + asfAllowTrustLineClawback = 17, } /** diff --git a/packages/xahau/src/models/transactions/clawback.ts b/packages/xahau/src/models/transactions/clawback.ts new file mode 100644 index 00000000..fb51859c --- /dev/null +++ b/packages/xahau/src/models/transactions/clawback.ts @@ -0,0 +1,49 @@ +import { ValidationError } from '../../errors' +import { IssuedCurrencyAmount } from '../common' + +import { + BaseTransaction, + validateBaseTransaction, + isIssuedCurrency, +} from './common' + +/** + * The Clawback transaction is used by the token issuer to claw back + * issued tokens from a holder. + */ +export interface Clawback extends BaseTransaction { + TransactionType: 'Clawback' + /** + * Indicates the AccountID that submitted this transaction. The account MUST + * be the issuer of the currency. + */ + Account: string + /** + * The amount of currency to deliver, and it must be non-XRP. The nested field + * names MUST be lower-case. The `issuer` field MUST be the holder's address, + * whom to be clawed back. + */ + Amount: IssuedCurrencyAmount +} + +/** + * Verify the form and type of an Clawback at runtime. + * + * @param tx - An Clawback Transaction. + * @throws When the Clawback is Malformed. + */ +export function validateClawback(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Amount == null) { + throw new ValidationError('Clawback: missing field Amount') + } + + if (!isIssuedCurrency(tx.Amount)) { + throw new ValidationError('Clawback: invalid Amount') + } + + if (isIssuedCurrency(tx.Amount) && tx.Account === tx.Amount.issuer) { + throw new ValidationError('Clawback: invalid holder Account') + } +} diff --git a/packages/xahau/src/models/transactions/index.ts b/packages/xahau/src/models/transactions/index.ts index 509f5aac..3eab9bd5 100644 --- a/packages/xahau/src/models/transactions/index.ts +++ b/packages/xahau/src/models/transactions/index.ts @@ -61,3 +61,4 @@ export { URITokenCreateSellOffer } from './uriTokenCreateSellOffer' export { URITokenBuy } from './uriTokenBuy' export { URITokenCancelSellOffer } from './uriTokenCancelSellOffer' export { UNLModify } from './UNLModify' +export { Clawback } from './clawback' diff --git a/packages/xahau/src/models/transactions/transaction.ts b/packages/xahau/src/models/transactions/transaction.ts index cf8a5235..d4e56fdd 100644 --- a/packages/xahau/src/models/transactions/transaction.ts +++ b/packages/xahau/src/models/transactions/transaction.ts @@ -10,6 +10,7 @@ import { CheckCancel, validateCheckCancel } from './checkCancel' import { CheckCash, validateCheckCash } from './checkCash' import { CheckCreate, validateCheckCreate } from './checkCreate' import { ClaimReward, validateClaimReward } from './claimReward' +import { Clawback, validateClawback } from './clawback' import { BaseTransaction, isIssuedCurrency } from './common' import { DepositPreauth, validateDepositPreauth } from './depositPreauth' import { EnableAmendment } from './enableAmendment' @@ -66,6 +67,7 @@ export type SubmittableTransaction = | CheckCash | CheckCreate | ClaimReward + | Clawback | DepositPreauth | EscrowCancel | EscrowCreate @@ -204,6 +206,10 @@ export function validate(transaction: Record): void { validateClaimReward(tx) break + case 'Clawback': + validateClawback(tx) + break + case 'DepositPreauth': validateDepositPreauth(tx) break diff --git a/packages/xahau/test/integration/transactions/clawback.test.ts b/packages/xahau/test/integration/transactions/clawback.test.ts new file mode 100644 index 00000000..e0b3aedc --- /dev/null +++ b/packages/xahau/test/integration/transactions/clawback.test.ts @@ -0,0 +1,115 @@ +import { assert } from 'chai' + +import { + AccountSet, + AccountSetAsfFlags, + TrustSet, + Payment, + Clawback, +} from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { generateFundedWallet, testTransaction } from '../utils' + +// how long before each test case times out +const TIMEOUT = 20000 + +describe('Clawback', function () { + let testContext: XrplIntegrationTestContext + + beforeEach(async () => { + testContext = await setupClient(serverUrl) + }) + afterEach(async () => teardownClient(testContext)) + + it( + 'base', + async () => { + const wallet2 = await generateFundedWallet(testContext.client) + + const setupAccountSetTx: AccountSet = { + TransactionType: 'AccountSet', + Account: testContext.wallet.classicAddress, + SetFlag: AccountSetAsfFlags.asfAllowTrustLineClawback, + } + await testTransaction( + testContext.client, + setupAccountSetTx, + testContext.wallet, + ) + + const setupTrustSetTx: TrustSet = { + TransactionType: 'TrustSet', + Account: wallet2.classicAddress, + LimitAmount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction(testContext.client, setupTrustSetTx, wallet2) + + const setupPaymentTx: Payment = { + TransactionType: 'Payment', + Account: testContext.wallet.classicAddress, + Destination: wallet2.classicAddress, + Amount: { + currency: 'USD', + issuer: testContext.wallet.classicAddress, + value: '1000', + }, + } + await testTransaction( + testContext.client, + setupPaymentTx, + testContext.wallet, + ) + + // verify that line is created + const objectsResponse = await testContext.client.request({ + command: 'account_objects', + account: wallet2.classicAddress, + type: 'state', + }) + assert.lengthOf( + objectsResponse.result.account_objects, + 1, + 'Should be exactly one line on the ledger', + ) + + // actual test - clawback + const tx: Clawback = { + TransactionType: 'Clawback', + Account: testContext.wallet.classicAddress, + Amount: { + currency: 'USD', + issuer: wallet2.classicAddress, + value: '500', + }, + } + await testTransaction(testContext.client, tx, testContext.wallet) + + // verify amount clawed back + const linesResponse = await testContext.client.request({ + command: 'account_lines', + account: wallet2.classicAddress, + }) + + assert.lengthOf( + linesResponse.result.lines, + 1, + 'Should be exactly one line on the ledger', + ) + assert.equal( + '500', + linesResponse.result.lines[0].balance, + `Holder balance incorrect after Clawback`, + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xahau/test/models/clawback.test.ts b/packages/xahau/test/models/clawback.test.ts new file mode 100644 index 00000000..b60f7653 --- /dev/null +++ b/packages/xahau/test/models/clawback.test.ts @@ -0,0 +1,81 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' + +/** + * Clawback Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('Clawback', function () { + it(`verifies valid Clawback`, function () { + const validClawback = { + TransactionType: 'Clawback', + Amount: { + currency: 'DSH', + issuer: 'rcXY84C4g14iFp6taFXjjQGVeHqSCh9RX', + value: '43.11584856965009', + }, + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + } as any + + assert.doesNotThrow(() => validate(validClawback)) + }) + + it(`throws w/ missing Amount`, function () { + const missingAmount = { + TransactionType: 'Clawback', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + } as any + + assert.throws( + () => validate(missingAmount), + ValidationError, + 'Clawback: missing field Amount', + ) + }) + + it(`throws w/ invalid Amount`, function () { + const invalidAmount = { + TransactionType: 'Clawback', + Amount: 100000000, + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + } as any + + assert.throws( + () => validate(invalidAmount), + ValidationError, + 'Clawback: invalid Amount', + ) + + const invalidStrAmount = { + TransactionType: 'Clawback', + Amount: '1234', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + } as any + + assert.throws( + () => validate(invalidStrAmount), + ValidationError, + 'Clawback: invalid Amount', + ) + }) + + it(`throws w/ invalid holder Account`, function () { + const invalidAccount = { + TransactionType: 'Clawback', + Amount: { + currency: 'DSH', + issuer: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + value: '43.11584856965009', + }, + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + } as any + + assert.throws( + () => validate(invalidAccount), + ValidationError, + 'Clawback: invalid holder Account', + ) + }) +}) diff --git a/packages/xahau/test/models/utils.test.ts b/packages/xahau/test/models/utils.test.ts index 384378ca..edbac3ed 100644 --- a/packages/xahau/test/models/utils.test.ts +++ b/packages/xahau/test/models/utils.test.ts @@ -168,7 +168,8 @@ describe('Models Utils', function () { AccountRootFlags.lsfDisallowIncomingCheck | AccountRootFlags.lsfDisallowIncomingPayChan | AccountRootFlags.lsfDisallowIncomingTrustline | - AccountRootFlags.lsfDisallowIncomingRemit + AccountRootFlags.lsfDisallowIncomingRemit | + AccountRootFlags.lsfAllowTrustLineClawback const parsed = parseAccountRootFlags(accountRootFlags) @@ -186,7 +187,8 @@ describe('Models Utils', function () { parsed.lsfDisallowIncomingCheck && parsed.lsfDisallowIncomingPayChan && parsed.lsfDisallowIncomingTrustline && - parsed.lsfDisallowIncomingRemit, + parsed.lsfDisallowIncomingRemit && + parsed.lsfAllowTrustLineClawback, ) }) @@ -207,6 +209,7 @@ describe('Models Utils', function () { assert.isUndefined(parsed.lsfDisallowIncomingPayChan) assert.isUndefined(parsed.lsfDisallowIncomingTrustline) assert.isUndefined(parsed.lsfDisallowIncomingRemit) + assert.isUndefined(parsed.lsfAllowTrustLineClawback) }) it('parseTransactionFlags all enabled', function () {