diff --git a/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java b/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java
index 407517fea..990428edd 100644
--- a/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java
+++ b/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java
@@ -54,14 +54,16 @@ import org.joda.time.Duration;
* Acquisition) process in order to transfer a (possibly large) list of domains from one registrar
* to another, though it may be used in other situations as well.
*
+ *
The body of the HTTP post request should be a JSON list of the domains to be transferred.
+ * Because the list of domains to process can be quite large, this action should be called by a tool
+ * that batches the list of domains into reasonable sizes if necessary. The recommended usage path
+ * is to call this through the {@link google.registry.tools.BulkDomainTransferCommand}, which
+ * handles batching and input handling.
+ *
*
This runs as a single-threaded idempotent action that runs a superuser domain transfer on each
* domain to process. We go through the standard EPP process to make sure that we have an accurate
* historical representation of events (rather than force-modifying the domains in place).
*
- *
The body of the HTTP post request should be a JSON list of the domains to be transferred.
- * Because the list of domains to process can be quite large, this action should be called by a tool
- * that batches the list of domains into reasonable sizes if necessary.
- *
*
Consider passing in an "maxQps" parameter based on the number of domains being transferred,
* otherwise the default is {@link BatchModule#DEFAULT_MAX_QPS}.
*/
diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java
index 58992d4b5..2444567c8 100644
--- a/core/src/main/java/google/registry/module/RequestComponent.java
+++ b/core/src/main/java/google/registry/module/RequestComponent.java
@@ -17,6 +17,7 @@ package google.registry.module;
import dagger.Module;
import dagger.Subcomponent;
import google.registry.batch.BatchModule;
+import google.registry.batch.BulkDomainTransferAction;
import google.registry.batch.CannedScriptExecutionAction;
import google.registry.batch.DeleteExpiredDomainsAction;
import google.registry.batch.DeleteLoadTestDataAction;
@@ -171,6 +172,8 @@ interface RequestComponent {
BsaValidateAction bsaValidateAction();
+ BulkDomainTransferAction bulkDomainTransferAction();
+
CannedScriptExecutionAction cannedScriptExecutionAction();
CheckApiAction checkApiAction();
diff --git a/core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java b/core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java
new file mode 100644
index 000000000..18482f15d
--- /dev/null
+++ b/core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java
@@ -0,0 +1,158 @@
+// Copyright 2025 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.tools;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.io.Files;
+import com.google.common.net.MediaType;
+import com.google.gson.Gson;
+import google.registry.batch.BulkDomainTransferAction;
+import google.registry.model.registrar.Registrar;
+import google.registry.util.DomainNameUtils;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * A command to bulk-transfer any number of domains from one registrar to another.
+ *
+ *
This should be used as part of the BTAPPA (Bulk Transfer After a Partial Portfolio
+ * Acquisition) process in order to transfer a (possibly large) list of domains from one registrar
+ * to another, though it may be used in other situations as well.
+ *
+ *
For a true bulk transfer of domains, one should pass in a file with a list of domains (one per
+ * line) but if we need to do an ad-hoc transfer of one domain we can do that as well.
+ *
+ *
For BTAPPA purposes, we expect "requestedByRegistrar" to be true; this may not be the case for
+ * other purposes e.g. legal compliance transfers.
+ */
+@Parameters(
+ separators = " =",
+ commandDescription = "Transfer domain(s) in bulk with immediate effect.")
+public class BulkDomainTransferCommand extends ConfirmingCommand implements CommandWithConnection {
+
+ // we don't need any configuration on the Gson because all we need is a list of strings
+ private static final Gson GSON = new Gson();
+ private static final int DOMAIN_TRANSFER_BATCH_SIZE = 1000;
+
+ @Parameter(
+ names = {"--domains"},
+ description =
+ "Comma-separated list of domains to transfer, otherwise use --domain_names_file to"
+ + " specify a possibly-large list of domains")
+ private List domains;
+
+ @Parameter(
+ names = {"-d", "--domain_names_file"},
+ description = "A file with a list of newline-delimited domain names to create tokens for")
+ private String domainNamesFile;
+
+ @Parameter(
+ names = {"-g", "--gaining_registrar_id"},
+ description = "The ID of the registrar to which domains should be transferred",
+ required = true)
+ private String gainingRegistrarId;
+
+ @Parameter(
+ names = {"-l", "--losing_registrar_id"},
+ description = "The ID of the registrar from which domains should be transferred",
+ required = true)
+ private String losingRegistrarId;
+
+ @Parameter(
+ names = {"--reason"},
+ description = "Reason to transfer the domains",
+ required = true)
+ private String reason;
+
+ @Parameter(
+ names = {"--registrar_request"},
+ description = "Whether the change was requested by a registrar.")
+ private boolean requestedByRegistrar = false;
+
+ @Parameter(
+ names = {"--max_qps"},
+ description =
+ "Maximum queries to run per second, otherwise the default (maxQps) will be used")
+ private int maxQps;
+
+ private ServiceConnection connection;
+
+ @Override
+ public void setConnection(ServiceConnection connection) {
+ this.connection = connection;
+ }
+
+ @Override
+ protected String prompt() throws Exception {
+ checkArgument(
+ domainNamesFile != null ^ (domains != null && !domains.isEmpty()),
+ "Must specify exactly one input method, either --domains or --domain_names_file");
+ return String.format("Attempt to transfer %d domains?", getDomainList().size());
+ }
+
+ @Override
+ protected String execute() throws Exception {
+ checkArgument(
+ Registrar.loadByRegistrarIdCached(gainingRegistrarId).isPresent(),
+ "Gaining registrar %s doesn't exist",
+ gainingRegistrarId);
+ checkArgument(
+ Registrar.loadByRegistrarIdCached(losingRegistrarId).isPresent(),
+ "Losing registrar %s doesn't exist",
+ losingRegistrarId);
+
+ ImmutableMap.Builder paramsBuilder = new ImmutableMap.Builder<>();
+ paramsBuilder.put("gainingRegistrarId", gainingRegistrarId);
+ paramsBuilder.put("losingRegistrarId", losingRegistrarId);
+ paramsBuilder.put("requestedByRegistrar", requestedByRegistrar);
+ paramsBuilder.put("reason", reason);
+ if (maxQps > 0) {
+ paramsBuilder.put("maxQps", maxQps);
+ }
+ ImmutableMap params = paramsBuilder.build();
+
+ for (List batch : Iterables.partition(getDomainList(), DOMAIN_TRANSFER_BATCH_SIZE)) {
+ System.out.printf("Sending batch of %d domains\n", batch.size());
+ byte[] domainsList = GSON.toJson(batch).getBytes(UTF_8);
+ System.out.println(
+ connection.sendPostRequest(
+ BulkDomainTransferAction.PATH, params, MediaType.PLAIN_TEXT_UTF_8, domainsList));
+ }
+ return "";
+ }
+
+ private ImmutableList getDomainList() throws IOException {
+ return domainNamesFile == null ? ImmutableList.copyOf(domains) : loadDomainsFromFile();
+ }
+
+ private ImmutableList loadDomainsFromFile() throws IOException {
+ return Splitter.on('\n')
+ .omitEmptyStrings()
+ .trimResults()
+ .splitToStream(Files.asCharSource(new File(domainNamesFile), UTF_8).read())
+ .map(DomainNameUtils::canonicalizeHostname)
+ .collect(toImmutableList());
+ }
+}
diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java
index d5b08e8b4..0a38ff6d1 100644
--- a/core/src/main/java/google/registry/tools/RegistryTool.java
+++ b/core/src/main/java/google/registry/tools/RegistryTool.java
@@ -30,6 +30,7 @@ public final class RegistryTool {
public static final ImmutableMap> COMMAND_MAP =
new ImmutableMap.Builder>()
.put("ack_poll_messages", AckPollMessagesCommand.class)
+ .put("bulk_domain_transfer", BulkDomainTransferCommand.class)
.put("canonicalize_labels", CanonicalizeLabelsCommand.class)
.put("check_domain", CheckDomainCommand.class)
.put("check_domain_claims", CheckDomainClaimsCommand.class)
diff --git a/core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java b/core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java
new file mode 100644
index 000000000..f77799802
--- /dev/null
+++ b/core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java
@@ -0,0 +1,168 @@
+// Copyright 2025 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.tools;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.CharSink;
+import com.google.common.io.Files;
+import com.google.common.net.MediaType;
+import java.io.File;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/** Tests fir {@link BulkDomainTransferCommand}. */
+public class BulkDomainTransferCommandTest extends CommandTestCase {
+
+ private ServiceConnection connection;
+
+ @BeforeEach
+ void beforeEach() {
+ connection = mock(ServiceConnection.class);
+ command.setConnection(connection);
+ }
+
+ @Test
+ void testSuccess_validParametersSent() throws Exception {
+ runCommandForced(
+ "--gaining_registrar_id", "NewRegistrar",
+ "--losing_registrar_id", "TheRegistrar",
+ "--reason", "someReason",
+ "--domains", "foo.tld,bar.tld");
+ assertInStdout("Sending batch of 2 domains");
+ verify(connection)
+ .sendPostRequest(
+ "/_dr/task/bulkDomainTransfer",
+ ImmutableMap.of(
+ "gainingRegistrarId",
+ "NewRegistrar",
+ "losingRegistrarId",
+ "TheRegistrar",
+ "requestedByRegistrar",
+ false,
+ "reason",
+ "someReason"),
+ MediaType.PLAIN_TEXT_UTF_8,
+ "[\"foo.tld\",\"bar.tld\"]".getBytes(UTF_8));
+ }
+
+ @Test
+ void testSuccess_fileInBatches() throws Exception {
+ File domainNamesFile = tmpDir.resolve("domain_names.txt").toFile();
+ CharSink sink = Files.asCharSink(domainNamesFile, UTF_8);
+ sink.writeLines(IntStream.range(0, 1003).mapToObj(i -> String.format("foo%d.tld", i)));
+ runCommandForced(
+ "--gaining_registrar_id", "NewRegistrar",
+ "--losing_registrar_id", "TheRegistrar",
+ "--reason", "someReason",
+ "--domain_names_file", domainNamesFile.getPath());
+ assertInStdout("Sending batch of 1000 domains");
+ assertInStdout("Sending batch of 3 domains");
+ ArgumentCaptor listCaptor = ArgumentCaptor.forClass(byte[].class);
+ verify(connection, times(2))
+ .sendPostRequest(
+ eq("/_dr/task/bulkDomainTransfer"),
+ eq(
+ ImmutableMap.of(
+ "gainingRegistrarId",
+ "NewRegistrar",
+ "losingRegistrarId",
+ "TheRegistrar",
+ "requestedByRegistrar",
+ false,
+ "reason",
+ "someReason")),
+ eq(MediaType.PLAIN_TEXT_UTF_8),
+ listCaptor.capture());
+ assertThat(listCaptor.getValue())
+ .isEqualTo("[\"foo1000.tld\",\"foo1001.tld\",\"foo1002.tld\"]".getBytes(UTF_8));
+ }
+
+ @Test
+ void testFailure_badGaining() {
+ assertThat(
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ runCommandForced(
+ "--gaining_registrar_id", "Bad",
+ "--losing_registrar_id", "TheRegistrar",
+ "--reason", "someReason",
+ "--domains", "foo.tld,baz.tld")))
+ .hasMessageThat()
+ .isEqualTo("Gaining registrar Bad doesn't exist");
+ }
+
+ @Test
+ void testFailure_badLosing() {
+ assertThat(
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ runCommandForced(
+ "--gaining_registrar_id", "NewRegistrar",
+ "--losing_registrar_id", "Bad",
+ "--reason", "someReason",
+ "--domains", "foo.tld,baz.tld")))
+ .hasMessageThat()
+ .isEqualTo("Losing registrar Bad doesn't exist");
+ }
+
+ @Test
+ void testFailure_noDomainsSpecified() {
+ assertThat(
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ runCommandForced(
+ "--gaining_registrar_id", "NewRegistrar",
+ "--losing_registrar_id", "TheRegistrar",
+ "--reason", "someReason")))
+ .hasMessageThat()
+ .isEqualTo(
+ "Must specify exactly one input method, either --domains or --domain_names_file");
+ }
+
+ @Test
+ void testFailure_bothDomainMethodsSpecified() {
+ assertThat(
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ runCommandForced(
+ "--gaining_registrar_id",
+ "NewRegistrar",
+ "--losing_registrar_id",
+ "TheRegistrar",
+ "--reason",
+ "someReason",
+ "--domains",
+ "foo.tld,baz.tld",
+ "--domain_names_file",
+ "foo.txt")))
+ .hasMessageThat()
+ .isEqualTo(
+ "Must specify exactly one input method, either --domains or --domain_names_file");
+ }
+}
diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt
index ae365ca2d..10048de4b 100644
--- a/core/src/test/resources/google/registry/module/routing.txt
+++ b/core/src/test/resources/google/registry/module/routing.txt
@@ -17,6 +17,7 @@ BACKEND /_dr/task/brdaCopy BrdaCopyAction
BACKEND /_dr/task/bsaDownload BsaDownloadAction GET,POST n APP ADMIN
BACKEND /_dr/task/bsaRefresh BsaRefreshAction GET,POST n APP ADMIN
BACKEND /_dr/task/bsaValidate BsaValidateAction GET,POST n APP ADMIN
+BACKEND /_dr/task/bulkDomainTransfer BulkDomainTransferAction POST n APP ADMIN
BACKEND /_dr/task/copyDetailReports CopyDetailReportsAction POST n APP ADMIN
BACKEND /_dr/task/deleteExpiredDomains DeleteExpiredDomainsAction GET n APP ADMIN
BACKEND /_dr/task/deleteLoadTestData DeleteLoadTestDataAction POST n APP ADMIN