diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index ec4572c109..2450759062 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -173,6 +173,7 @@ fixXChainRewardRounding fixPreviousTxnID fixAMMv1_1 # 2.3.0 Amendments +AMMClawback fixAMMv1_2 Credentials NFTokenMintOffer diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index 91d3ef8e9f..54e15f0f6f 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +* Support for the AMMClawback amendment (XLS-73) + ## 2.2.0 (2024-12-23) ### Added diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index e50863370b..92e90c8e6b 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -3073,6 +3073,7 @@ }, "TRANSACTION_TYPES": { "AMMBid": 39, + "AMMClawback": 31, "AMMCreate": 35, "AMMDelete": 40, "AMMDeposit": 36, diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index b5f4e03129..0d35e416ee 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -5,6 +5,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ## Unreleased ### Added +* Support for the AMMClawback amendment (XLS-73) * Adds utility function `convertTxFlagsToNumber` * Implementation of XLS-80d PermissionedDomain feature. * Support for the `simulate` RPC ([XLS-69](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0069-simulate)) diff --git a/packages/xrpl/src/models/transactions/AMMClawback.ts b/packages/xrpl/src/models/transactions/AMMClawback.ts new file mode 100644 index 0000000000..7ef4b2ca85 --- /dev/null +++ b/packages/xrpl/src/models/transactions/AMMClawback.ts @@ -0,0 +1,120 @@ +import { ValidationError } from '../../errors' +import { Currency, IssuedCurrency, IssuedCurrencyAmount } from '../common' + +import { + Account, + BaseTransaction, + GlobalFlags, + isAccount, + isAmount, + isCurrency, + validateBaseTransaction, + validateOptionalField, + validateRequiredField, +} from './common' + +/** + * Enum representing values for AMMClawback Transaction Flags. + * + * @category Transaction Flags + */ +export enum AMMClawbackFlags { + tfClawTwoAssets = 0x00000001, +} + +/** + * Map of flags to boolean values representing {@link AMMClawback} transaction + * flags. + * + * @category Transaction Flags + */ +export interface AMMClawbackFlagsInterface extends GlobalFlags { + tfClawTwoAssets?: boolean +} + +/** + * Claw back tokens from a holder that has deposited your issued tokens into an AMM pool. + * + * Clawback is disabled by default. To use clawback, you must send an AccountSet transaction to enable the + * Allow Trust Line Clawback setting. An issuer with any existing tokens cannot enable clawback. You can + * only enable Allow Trust Line Clawback if you have a completely empty owner directory, meaning you must + * do so before you set up any trust lines, offers, escrows, payment channels, checks, or signer lists. + * After you enable clawback, it cannot reverted: the account permanently gains the ability to claw back + * issued assets on trust lines. + */ +export interface AMMClawback extends BaseTransaction { + TransactionType: 'AMMClawback' + + /** + * The account holding the asset to be clawed back. + */ + Holder: Account + + /** + * Specifies the asset that the issuer wants to claw back from the AMM pool. + * In JSON, this is an object with currency and issuer fields. The issuer field must match with Account. + */ + Asset: IssuedCurrency + + /** + * Specifies the other asset in the AMM's pool. In JSON, this is an object with currency and + * issuer fields (omit issuer for XRP). + */ + Asset2: Currency + + /** + * The maximum amount to claw back from the AMM account. The currency and issuer subfields should match + * the Asset subfields. If this field isn't specified, or the value subfield exceeds the holder's available + * tokens in the AMM, all of the holder's tokens will be clawed back. + */ + Amount?: IssuedCurrencyAmount +} + +/** + * Verify the form and type of an AMMClawback at runtime. + * + * @param tx - An AMMClawback Transaction. + * @throws {ValidationError} When the transaction is malformed. + */ +export function validateAMMClawback(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'Holder', isAccount) + + validateRequiredField(tx, 'Asset', isCurrency) + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- required + const asset = tx.Asset as IssuedCurrency + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- required + const amount = tx.Amount as IssuedCurrencyAmount + + if (tx.Holder === asset.issuer) { + throw new ValidationError( + 'AMMClawback: Holder and Asset.issuer must be distinct', + ) + } + + if (tx.Account !== asset.issuer) { + throw new ValidationError( + 'AMMClawback: Account must be the same as Asset.issuer', + ) + } + + validateRequiredField(tx, 'Asset2', isCurrency) + + validateOptionalField(tx, 'Amount', isAmount) + + if (tx.Amount != null) { + if (amount.currency !== asset.currency) { + throw new ValidationError( + 'AMMClawback: Amount.currency must match Asset.currency', + ) + } + + if (amount.issuer !== asset.issuer) { + throw new ValidationError( + 'AMMClawback: Amount.issuer must match Amount.issuer', + ) + } + } +} diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index de619f13bf..f5e4bf0e81 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -15,13 +15,18 @@ export { } from './accountSet' export { AccountDelete } from './accountDelete' export { AMMBid } from './AMMBid' +export { + AMMClawbackFlags, + AMMClawbackFlagsInterface, + AMMClawback, +} from './AMMClawback' +export { AMMCreate } from './AMMCreate' export { AMMDelete } from './AMMDelete' export { AMMDepositFlags, AMMDepositFlagsInterface, AMMDeposit, } from './AMMDeposit' -export { AMMCreate } from './AMMCreate' export { AMMVote } from './AMMVote' export { AMMWithdrawFlags, diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index 078f9eba50..d803ca4201 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -9,6 +9,7 @@ import { convertTxFlagsToNumber } from '../utils/flags' import { AccountDelete, validateAccountDelete } from './accountDelete' import { AccountSet, validateAccountSet } from './accountSet' import { AMMBid, validateAMMBid } from './AMMBid' +import { AMMClawback, validateAMMClawback } from './AMMClawback' import { AMMCreate, validateAMMCreate } from './AMMCreate' import { AMMDelete, validateAMMDelete } from './AMMDelete' import { AMMDeposit, validateAMMDeposit } from './AMMDeposit' @@ -123,6 +124,7 @@ import { */ export type SubmittableTransaction = | AMMBid + | AMMClawback | AMMCreate | AMMDelete | AMMDeposit @@ -273,6 +275,10 @@ export function validate(transaction: Record): void { validateAMMBid(tx) break + case 'AMMClawback': + validateAMMClawback(tx) + break + case 'AMMCreate': validateAMMCreate(tx) break diff --git a/packages/xrpl/src/models/utils/flags.ts b/packages/xrpl/src/models/utils/flags.ts index c2a88662a7..45e4cc9919 100644 --- a/packages/xrpl/src/models/utils/flags.ts +++ b/packages/xrpl/src/models/utils/flags.ts @@ -5,6 +5,7 @@ import { AccountRootFlags, } from '../ledger/AccountRoot' import { AccountSetTfFlags } from '../transactions/accountSet' +import { AMMClawbackFlags } from '../transactions/AMMClawback' import { AMMDepositFlags } from '../transactions/AMMDeposit' import { AMMWithdrawFlags } from '../transactions/AMMWithdraw' import { MPTokenAuthorizeFlags } from '../transactions/MPTokenAuthorize' @@ -47,6 +48,7 @@ export function parseAccountRootFlags( const txToFlag = { AccountSet: AccountSetTfFlags, + AMMClawback: AMMClawbackFlags, AMMDeposit: AMMDepositFlags, AMMWithdraw: AMMWithdrawFlags, MPTokenAuthorize: MPTokenAuthorizeFlags, diff --git a/packages/xrpl/test/integration/transactions/ammClawback.test.ts b/packages/xrpl/test/integration/transactions/ammClawback.test.ts new file mode 100644 index 0000000000..211c4c6f83 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/ammClawback.test.ts @@ -0,0 +1,57 @@ +import { AMMClawback, AMMDeposit, AMMDepositFlags, XRP } from 'xrpl' + +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { createAMMPool, testTransaction } from '../utils' + +describe('AMMClawback', function () { + let testContext: XrplIntegrationTestContext + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const ammPool = await createAMMPool(testContext.client, true) + const { issuerWallet } = ammPool + const holderWallet = ammPool.lpWallet + + const asset = { + currency: 'USD', + issuer: issuerWallet.classicAddress, + } + const asset2 = { + currency: 'XRP', + } as XRP + + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: holderWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: { + currency: 'USD', + issuer: issuerWallet.address, + value: '10', + }, + Flags: AMMDepositFlags.tfSingleAsset, + } + + await testTransaction(testContext.client, ammDepositTx, holderWallet) + + const ammClawback: AMMClawback = { + TransactionType: 'AMMClawback', + Account: issuerWallet.address, + Holder: holderWallet.address, + Asset: asset, + Asset2: asset2, + } + + await testTransaction(testContext.client, ammClawback, issuerWallet) + }) +}) diff --git a/packages/xrpl/test/integration/utils.ts b/packages/xrpl/test/integration/utils.ts index 87f4529acb..82d539bf31 100644 --- a/packages/xrpl/test/integration/utils.ts +++ b/packages/xrpl/test/integration/utils.ts @@ -373,7 +373,10 @@ export async function getIOUBalance( return (await client.request(request)).result.lines[0].balance } -export async function createAMMPool(client: Client): Promise<{ +export async function createAMMPool( + client: Client, + enableAMMClawback = false, +): Promise<{ issuerWallet: Wallet lpWallet: Wallet asset: Currency @@ -391,6 +394,16 @@ export async function createAMMPool(client: Client): Promise<{ await testTransaction(client, accountSetTx, issuerWallet) + if (enableAMMClawback) { + const accountSetTx2: AccountSet = { + TransactionType: 'AccountSet', + Account: issuerWallet.classicAddress, + SetFlag: AccountSetAsfFlags.asfAllowTrustLineClawback, + } + + await testTransaction(client, accountSetTx2, issuerWallet) + } + const trustSetTx: TrustSet = { TransactionType: 'TrustSet', Flags: TrustSetFlags.tfClearNoRipple, diff --git a/packages/xrpl/test/models/AMMClawback.test.ts b/packages/xrpl/test/models/AMMClawback.test.ts new file mode 100644 index 0000000000..a64623fbe2 --- /dev/null +++ b/packages/xrpl/test/models/AMMClawback.test.ts @@ -0,0 +1,176 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { + AMMClawbackFlags, + validateAMMClawback, +} from '../../src/models/transactions/AMMClawback' + +/** + * AMMClawback Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMClawback', function () { + let ammClawback + + beforeEach(function () { + ammClawback = { + TransactionType: 'AMMClawback', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Holder: 'rPyfep3gcLzkosKC9XiE77Y8DZWG6iWDT9', + Asset: { + currency: 'USD', + issuer: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + }, + Asset2: { + currency: 'XRP', + }, + Amount: { + currency: 'USD', + issuer: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + value: '1000', + }, + Sequence: 1337, + } as any + }) + + it(`verifies valid AMMClawback`, function () { + assert.doesNotThrow(() => validateAMMClawback(ammClawback)) + assert.doesNotThrow(() => validate(ammClawback)) + }) + + it(`verifies valid AMMClawback without Amount`, function () { + delete ammClawback.Amount + assert.doesNotThrow(() => validateAMMClawback(ammClawback)) + assert.doesNotThrow(() => validate(ammClawback)) + }) + + it(`verifies valid AMMClawback with tfClawTwoAssets`, function () { + ammClawback.flags = AMMClawbackFlags.tfClawTwoAssets + assert.doesNotThrow(() => validateAMMClawback(ammClawback)) + assert.doesNotThrow(() => validate(ammClawback)) + }) + + it(`throws w/ missing Holder`, function () { + delete ammClawback.Holder + const errorMessage = 'AMMClawback: missing field Holder' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Holder`, function () { + ammClawback.Holder = 1234 + const errorMessage = 'AMMClawback: invalid field Holder' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Holder and Asset.issuer must be distinct`, function () { + ammClawback.Holder = ammClawback.Asset.issuer + const errorMessage = 'AMMClawback: Holder and Asset.issuer must be distinct' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ missing Asset`, function () { + delete ammClawback.Asset + const errorMessage = 'AMMClawback: missing field Asset' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Asset`, function () { + ammClawback.Asset = '1000' + const errorMessage = 'AMMClawback: invalid field Asset' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Account must be the same as Asset.issuer`, function () { + ammClawback.Account = 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn' + const errorMessage = 'AMMClawback: Account must be the same as Asset.issuer' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ missing Asset2`, function () { + delete ammClawback.Asset2 + const errorMessage = 'AMMClawback: missing field Asset2' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Asset2`, function () { + ammClawback.Asset2 = '1000' + const errorMessage = 'AMMClawback: invalid field Asset2' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Amount`, function () { + ammClawback.Amount = 1000 + const errorMessage = 'AMMClawback: invalid field Amount' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Amount.currency must match Asset.currency`, function () { + ammClawback.Amount.currency = 'ETH' + const errorMessage = + 'AMMClawback: Amount.currency must match Asset.currency' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Amount.issuer must match Amount.issuer`, function () { + ammClawback.Amount.issuer = 'rnYgaEtpqpNRt3wxE39demVpDAA817rQEY' + const errorMessage = 'AMMClawback: Amount.issuer must match Amount.issuer' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) +})