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>
This commit is contained in:
tequ
2025-10-03 01:50:42 +09:00
committed by GitHub
parent 83a25c6c34
commit 043620b637
10 changed files with 270 additions and 2 deletions

View File

@@ -167,6 +167,7 @@ fixXahauV1
fixXahauV2
fixXahauV3
PaychanAndEscrowForTokens
Clawback
[network_id]
21337

View File

@@ -2829,6 +2829,7 @@
"NFTokenCreateOffer": 27,
"NFTokenCancelOffer": 28,
"NFTokenAcceptOffer": 29,
"Clawback": 30,
"URITokenMint": 45,
"URITokenBurn": 46,
"URITokenBuy": 47,

View File

@@ -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,
}

View File

@@ -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,
}
/**

View File

@@ -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<string, unknown>): 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')
}
}

View File

@@ -61,3 +61,4 @@ export { URITokenCreateSellOffer } from './uriTokenCreateSellOffer'
export { URITokenBuy } from './uriTokenBuy'
export { URITokenCancelSellOffer } from './uriTokenCancelSellOffer'
export { UNLModify } from './UNLModify'
export { Clawback } from './clawback'

View File

@@ -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<string, unknown>): void {
validateClaimReward(tx)
break
case 'Clawback':
validateClawback(tx)
break
case 'DepositPreauth':
validateDepositPreauth(tx)
break

View File

@@ -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,
)
})

View File

@@ -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',
)
})
})

View File

@@ -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 () {